git ssb

30+

cel / git-ssb-web



Tree: bdbcf3382e534954bdeab3f996415d9bc9077a57

Files: bdbcf3382e534954bdeab3f996415d9bc9077a57 / index.js

83496 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 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 }
897 }
898
899 /* Index */
900
901 function serveIndex(req) {
902 return serveTemplate('git ssb')(renderFeed(req))
903 }
904
905 function serveUserPage(req, feedId, dirs) {
906 switch (dirs[0]) {
907 case undefined:
908 case '':
909 case 'activity':
910 return serveUserActivity(req, feedId)
911 case 'repos':
912 return serveUserRepos(feedId)
913 }
914 }
915
916 function renderUserPage(feedId, page, body) {
917 return serveTemplate(feedId)(cat([
918 readOnce(function (cb) {
919 about.getName(feedId, function (err, name) {
920 cb(null, '<h2>' + link([feedId], name) +
921 '<code class="user-id">' + feedId + '</code></h2>' +
922 nav([
923 [[feedId], 'Activity', 'activity'],
924 [[feedId, 'repos'], 'Repos', 'repos']
925 ], page))
926 })
927 }),
928 body,
929 ]))
930 }
931
932 function serveUserActivity(req, feedId) {
933 return renderUserPage(feedId, 'activity', renderFeed(req, feedId))
934 }
935
936 function serveUserRepos(feedId) {
937 return renderUserPage(feedId, 'repos', pull(
938 ssb.messagesByType({
939 type: 'git-repo',
940 reverse: true
941 }),
942 pull.filter(function (msg) {
943 return msg.value.author == feedId
944 }),
945 pull.take(20),
946 paramap(function (msg, cb) {
947 getRepoName(about, feedId, msg.key, function (err, repoName) {
948 if (err) return cb(err)
949 cb(null, '<section class="collapse">' +
950 link([msg.key], repoName) +
951 '</section>')
952 })
953 }, 8)
954 ))
955 }
956
957 /* Message */
958
959 function serveMessage(req, id, path) {
960 return readNext(function (cb) {
961 ssb.get(id, function (err, msg) {
962 if (err) return cb(null, serveError(err))
963 var c = msg.content || {}
964 switch (c.type) {
965 case 'git-repo':
966 return getRepo(id, function (err, repo) {
967 if (err) return cb(null, serveError(err))
968 cb(null, serveRepoPage(req, Repo(repo), path))
969 })
970 case 'git-update':
971 return getRepo(c.repo, function (err, repo) {
972 if (err) return cb(null, serveRepoNotFound(c.repo, err))
973 cb(null, serveRepoUpdate(req, Repo(repo), id, msg, path))
974 })
975 case 'issue':
976 return getRepo(c.project, function (err, repo) {
977 if (err) return cb(null, serveRepoNotFound(c.project, err))
978 issues.get(id, function (err, issue) {
979 if (err) return cb(null, serveError(err))
980 cb(null, serveRepoIssue(req, Repo(repo), issue, path))
981 })
982 })
983 case 'pull-request':
984 return getRepo(c.repo, function (err, repo) {
985 if (err) return cb(null, serveRepoNotFound(c.project, err))
986 pullReqs.get(id, function (err, pr) {
987 if (err) return cb(null, serveError(err))
988 cb(null, serveRepoPullReq(req, Repo(repo), pr, path))
989 })
990 })
991 case 'issue-edit':
992 if (ref.isMsgId(c.issue)) {
993 return pullReqs.get(c.issue, function (err, issue) {
994 if (err) return cb(err)
995 var serve = issue.msg.value.content.type == 'pull-request' ?
996 serveRepoPullReq : serveRepoIssue
997 getRepo(issue.project, function (err, repo) {
998 if (err) {
999 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
1000 return cb(null, serveError(err))
1001 }
1002 cb(null, serve(req, Repo(repo), issue, path, id))
1003 })
1004 })
1005 }
1006 // fallthrough
1007 case 'post':
1008 if (ref.isMsgId(c.issue) && ref.isMsgId(c.repo)) {
1009 // comment on an issue
1010 var done = multicb({ pluck: 1, spread: true })
1011 getRepo(c.repo, done())
1012 pullReqs.get(c.issue, done())
1013 return done(function (err, repo, issue) {
1014 if (err) {
1015 if (!repo) return cb(null, serveRepoNotFound(c.repo, err))
1016 return cb(null, serveError(err))
1017 }
1018 var serve = issue.msg.value.content.type == 'pull-request' ?
1019 serveRepoPullReq : serveRepoIssue
1020 cb(null, serve(req, Repo(repo), issue, path, id))
1021 })
1022 } else if (ref.isMsgId(c.root)) {
1023 // comment on issue from patchwork?
1024 return getMsg(c.root, function (err, root) {
1025 if (err) return cb(null, serveError(err))
1026 var repoId = root.content.repo || root.content.project
1027 if (!ref.isMsgId(repoId))
1028 return cb(null, serveGenericMessage(req, id, msg, path))
1029 getRepo(repoId, function (err, repo) {
1030 if (err) return cb(null, serveError(err))
1031 switch (root.content && root.content.type) {
1032 case 'issue':
1033 return issues.get(c.root, function (err, issue) {
1034 if (err) return cb(null, serveError(err))
1035 return cb(null,
1036 serveRepoIssue(req, Repo(repo), issue, path, id))
1037 })
1038 case 'pull-request':
1039 pullReqs.get(c.root, function (err, pr) {
1040 if (err) return cb(null, serveError(err))
1041 return cb(null,
1042 serveRepoPullReq(req, Repo(repo), pr, path, id))
1043 })
1044 }
1045 })
1046 })
1047 }
1048 // fallthrough
1049 default:
1050 if (ref.isMsgId(c.repo))
1051 return getRepo(c.repo, function (err, repo) {
1052 if (err) return cb(null, serveRepoNotFound(c.repo, err))
1053 cb(null, serveRepoSomething(req, Repo(repo), id, msg, path))
1054 })
1055 else
1056 return cb(null, serveGenericMessage(req, id, msg, path))
1057 }
1058 })
1059 })
1060 }
1061
1062 function serveGenericMessage(req, id, msg, path) {
1063 return serveTemplate(id)(pull.once(
1064 '<section><h2>' + link([id]) + '</h2>' +
1065 json(msg) +
1066 '</section>'))
1067 }
1068
1069 /* Repo */
1070
1071 function serveRepoPage(req, repo, path) {
1072 var defaultBranch = 'master'
1073 var query = req._u.query
1074
1075 if (query.rev != null) {
1076 // Allow navigating revs using GET query param.
1077 // Replace the branch in the path with the rev query value
1078 path[0] = path[0] || 'tree'
1079 path[1] = query.rev
1080 req._u.pathname = encodeLink([repo.id].concat(path))
1081 delete req._u.query.rev
1082 delete req._u.search
1083 return serveRedirect(url.format(req._u))
1084 }
1085
1086 // get branch
1087 return path[1] ?
1088 serveRepoPage2(req, repo, path) :
1089 readNext(function (cb) {
1090 // TODO: handle this in pull-git-repo or ssb-git-repo
1091 repo.getSymRef('HEAD', true, function (err, ref) {
1092 if (err) return cb(err)
1093 repo.resolveRef(ref, function (err, rev) {
1094 path[1] = rev ? ref : null
1095 cb(null, serveRepoPage2(req, repo, path))
1096 })
1097 })
1098 })
1099 }
1100
1101 function serveRepoPage2(req, repo, path) {
1102 var branch = path[1]
1103 var filePath = path.slice(2)
1104 switch (path[0]) {
1105 case undefined:
1106 case '':
1107 return serveRepoTree(repo, branch, [])
1108 case 'activity':
1109 return serveRepoActivity(repo, branch)
1110 case 'commits':
1111 return serveRepoCommits(req, repo, branch)
1112 case 'commit':
1113 return serveRepoCommit(repo, path[1])
1114 case 'tree':
1115 return serveRepoTree(repo, branch, filePath)
1116 case 'blob':
1117 return serveRepoBlob(repo, branch, filePath)
1118 case 'raw':
1119 return serveRepoRaw(repo, branch, filePath)
1120 case 'digs':
1121 return serveRepoDigs(repo)
1122 case 'forks':
1123 return serveRepoForks(repo)
1124 case 'issues':
1125 switch (path[1]) {
1126 case 'new':
1127 if (filePath.length == 0)
1128 return serveRepoNewIssue(repo)
1129 break
1130 default:
1131 return serveRepoIssues(req, repo, filePath)
1132 }
1133 case 'pulls':
1134 return serveRepoPullReqs(req, repo)
1135 case 'compare':
1136 return serveRepoCompare(req, repo)
1137 case 'comparing':
1138 return serveRepoComparing(req, repo)
1139 default:
1140 return serve404(req)
1141 }
1142 }
1143
1144 function serveRepoNotFound(id, err) {
1145 return serveTemplate('Repo not found', 404)(pull.values([
1146 '<h2>Repo not found</h2>',
1147 '<p>Repo ' + id + ' was not found</p>',
1148 '<pre>' + escapeHTML(err.stack) + '</pre>',
1149 ]))
1150 }
1151
1152 function renderRepoPage(repo, page, branch, body) {
1153 var gitUrl = 'ssb://' + repo.id
1154 var gitLink = '<input class="clone-url" readonly="readonly" ' +
1155 'value="' + gitUrl + '" size="45" ' +
1156 'onclick="this.select()"/>'
1157 var digsPath = [repo.id, 'digs']
1158
1159 var done = multicb({ pluck: 1, spread: true })
1160 getRepoName(about, repo.feed, repo.id, done())
1161 about.getName(repo.feed, done())
1162 getVotes(repo.id, done())
1163
1164 if (repo.upstream) {
1165 getRepoName(about, repo.upstream.feed, repo.upstream.id, done())
1166 about.getName(repo.upstream.feed, done())
1167 }
1168
1169 return readNext(function (cb) {
1170 done(function (err, repoName, authorName, votes,
1171 upstreamName, upstreamAuthorName) {
1172 if (err) return cb(null, serveError(err))
1173 var upvoted = votes.upvoters[myId] > 0
1174 var upstreamLink = !repo.upstream ? '' :
1175 link([repo.upstream])
1176 cb(null, serveTemplate(repo.id)(cat([
1177 pull.once(
1178 '<div class="repo-title">' +
1179 '<form class="right-bar" action="" method="post">' +
1180 '<button class="btn" name="vote" ' +
1181 (isPublic ? 'disabled="disabled"' : ' type="submit"') + '>' +
1182 '<i>✌</i> ' + (!isPublic && upvoted ? 'Undig' : 'Dig') +
1183 '</button>' +
1184 (isPublic ? '' : '<input type="hidden" name="value" value="' +
1185 (upvoted ? '0' : '1') + '">' +
1186 '<input type="hidden" name="action" value="repo">' +
1187 '<input type="hidden" name="id" value="' +
1188 escapeHTML(repo.id) + '">') + ' ' +
1189 '<strong>' + link(digsPath, votes.upvotes) + '</strong> ' +
1190 (isPublic ? '' :
1191 '<button class="btn" type="submit" name="fork">' +
1192 '<i>⑂</i> Fork' +
1193 '</button>') + ' ' +
1194 link([repo.id, 'forks'], '+', false, ' title="Forks"') +
1195 '</form>' +
1196 renderNameForm(!isPublic, repo.id, repoName, 'repo-name', null,
1197 'Rename the repo',
1198 '<h2 class="bgslash">' + link([repo.feed], authorName) + ' / ' +
1199 link([repo.id], repoName) + '</h2>') +
1200 '</div>' +
1201 (repo.upstream ?
1202 '<small>forked from ' +
1203 link([repo.upstream.feed], upstreamAuthorName) + '\'s ' +
1204 link([repo.upstream.id], upstreamName) +
1205 '</small>' : '') +
1206 nav([
1207 [[repo.id], 'Code', 'code'],
1208 [[repo.id, 'activity'], 'Activity', 'activity'],
1209 [[repo.id, 'commits', branch || ''], 'Commits', 'commits'],
1210 [[repo.id, 'issues'], 'Issues', 'issues'],
1211 [[repo.id, 'pulls'], 'Pull Requests', 'pulls']
1212 ], page, gitLink)),
1213 body
1214 ])))
1215 })
1216 })
1217 }
1218
1219 function serveEmptyRepo(repo) {
1220 if (repo.feed != myId)
1221 return renderRepoPage(repo, 'code', null, pull.once(
1222 '<section>' +
1223 '<h3>Empty repository</h3>' +
1224 '</section>'))
1225
1226 var gitUrl = 'ssb://' + repo.id
1227 return renderRepoPage(repo, 'code', null, pull.once(
1228 '<section>' +
1229 '<h3>Getting started</h3>' +
1230 '<h4>Create a new repository</h4><pre>' +
1231 'touch README.md\n' +
1232 'git init\n' +
1233 'git add README.md\n' +
1234 'git commit -m "Initial commit"\n' +
1235 'git remote add origin ' + gitUrl + '\n' +
1236 'git push -u origin master</pre>\n' +
1237 '<h4>Push an existing repository</h4>\n' +
1238 '<pre>git remote add origin ' + gitUrl + '\n' +
1239 'git push -u origin master</pre>' +
1240 '</section>'))
1241 }
1242
1243 function serveRepoTree(repo, rev, path) {
1244 if (!rev) return serveEmptyRepo(repo)
1245 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1246 return renderRepoPage(repo, 'code', rev, cat([
1247 pull.once('<section><form action="" method="get">' +
1248 '<h3>' + type + ': ' + rev + ' '),
1249 revMenu(repo, rev),
1250 pull.once('</h3></form>'),
1251 type == 'Branch' && renderRepoLatest(repo, rev),
1252 pull.once('</section><section>'),
1253 renderRepoTree(repo, rev, path),
1254 pull.once('</section>'),
1255 renderRepoReadme(repo, rev, path)
1256 ]))
1257 }
1258
1259 /* Search */
1260
1261 function serveSearch(req) {
1262 var q = String(req._u.query.q || '')
1263 if (!q) return serveIndex(req)
1264 var qId = q.replace(/^ssb:\/*/, '')
1265 if (ref.type(qId))
1266 return serveRedirect(encodeURI(qId))
1267
1268 var search = new RegExp(q, 'i')
1269 return serveTemplate('git ssb search', 200, req)(
1270 renderFeed(req, null, pull.filter(function (msg) {
1271 var c = msg.value.content
1272 return (
1273 search.test(msg.key) ||
1274 c.text && search.test(c.text) ||
1275 c.title && search.test(c.title))
1276 }))
1277 )
1278 }
1279
1280 /* Repo activity */
1281
1282 function serveRepoActivity(repo, branch) {
1283 return renderRepoPage(repo, 'activity', branch, cat([
1284 pull.once('<h3>Activity</h3>'),
1285 pull(
1286 ssb.links({
1287 dest: repo.id,
1288 source: repo.feed,
1289 rel: 'repo',
1290 values: true,
1291 reverse: true
1292 }),
1293 pull.map(renderRepoUpdate.bind(this, repo))
1294 ),
1295 readOnce(function (cb) {
1296 var done = multicb({ pluck: 1, spread: true })
1297 about.getName(repo.feed, done())
1298 getMsg(repo.id, done())
1299 done(function (err, authorName, msg) {
1300 if (err) return cb(err)
1301 renderFeedItem({
1302 key: repo.id,
1303 value: msg,
1304 authorName: authorName
1305 }, cb)
1306 })
1307 })
1308 ]))
1309 }
1310
1311 function renderRepoUpdate(repo, msg, full) {
1312 var c = msg.value.content
1313
1314 if (c.type != 'git-update') {
1315 return ''
1316 // return renderFeedItem(msg, cb)
1317 // TODO: render post, issue, pull-request
1318 }
1319
1320 var refs = c.refs ? Object.keys(c.refs).map(function (ref) {
1321 return {name: ref, value: c.refs[ref]}
1322 }) : []
1323 var numObjects = c.objects ? Object.keys(c.objects).length : 0
1324
1325 return '<section class="collapse">' +
1326 link([msg.key], new Date(msg.value.timestamp).toLocaleString()) +
1327 '<br>' +
1328 (numObjects ? 'Pushed ' + numObjects + ' objects<br>' : '') +
1329 refs.map(function (update) {
1330 var name = escapeHTML(update.name)
1331 if (!update.value) {
1332 return 'Deleted ' + name
1333 } else {
1334 var commitLink = link([repo.id, 'commit', update.value])
1335 return name + ' &rarr; ' + commitLink
1336 }
1337 }).join('<br>') +
1338 '</section>'
1339 }
1340
1341 /* Repo commits */
1342
1343 function serveRepoCommits(req, repo, branch) {
1344 var query = req._u.query
1345 return renderRepoPage(repo, 'commits', branch, cat([
1346 pull.once('<h3>Commits</h3>'),
1347 pull(
1348 repo.readLog(query.start || branch),
1349 pull.take(20),
1350 paramap(repo.getCommitParsed.bind(repo), 8),
1351 paginate(
1352 !query.start ? '' : function (first, cb) {
1353 cb(null, '&hellip;')
1354 },
1355 pull.map(renderCommit.bind(this, repo)),
1356 function (commit, cb) {
1357 cb(null, commit.parents && commit.parents[0] ?
1358 '<a href="?start=' + commit.id + '">Older</a>' : '')
1359 }
1360 )
1361 )
1362 ]))
1363 }
1364
1365 function renderCommit(repo, commit) {
1366 var commitPath = [repo.id, 'commit', commit.id]
1367 var treePath = [repo.id, 'tree', commit.id]
1368 return '<section class="collapse">' +
1369 '<strong>' + link(commitPath, commit.title) + '</strong><br>' +
1370 '<tt>' + commit.id + '</tt> ' +
1371 link(treePath, 'Tree') + '<br>' +
1372 escapeHTML(commit.author.name) + ' &middot; ' + commit.author.date.toLocaleString() +
1373 (commit.separateAuthor ? '<br>' + escapeHTML(commit.committer.name) + ' committed on ' + commit.committer.date.toLocaleString() : "") +
1374 '</section>'
1375}
1376
1377 /* Branch menu */
1378
1379 function formatRevOptions(currentName) {
1380 return function (name) {
1381 var htmlName = escapeHTML(name)
1382 return '<option value="' + htmlName + '"' +
1383 (name == currentName ? ' selected="selected"' : '') +
1384 '>' + htmlName + '</option>'
1385 }
1386 }
1387
1388 function revMenu(repo, currentName) {
1389 return readOnce(function (cb) {
1390 repo.getRefNames(true, function (err, refs) {
1391 if (err) return cb(err)
1392 cb(null, '<select name="rev" onchange="this.form.submit()">' +
1393 Object.keys(refs).map(function (group) {
1394 return '<optgroup label="' + group + '">' +
1395 refs[group].map(formatRevOptions(currentName)).join('') +
1396 '</optgroup>'
1397 }).join('') +
1398 '</select><noscript> <input type="submit" value="Go"/></noscript>')
1399 })
1400 })
1401 }
1402
1403 function branchMenu(repo, name, currentName) {
1404 return cat([
1405 pull.once('<select name="' + name + '">'),
1406 pull(
1407 repo.refs(),
1408 pull.map(function (ref) {
1409 var m = ref.name.match(/^refs\/([^\/]*)\/(.*)$/) || [,, ref.name]
1410 return m[1] == 'heads' && m[2]
1411 }),
1412 pull.filter(Boolean),
1413 pullSort(),
1414 pull.map(formatRevOptions(currentName))
1415 ),
1416 pull.once('</select>')
1417 ])
1418 }
1419
1420 /* Repo tree */
1421
1422 function renderRepoLatest(repo, rev) {
1423 return readOnce(function (cb) {
1424 repo.getCommitParsed(rev, function (err, commit) {
1425 if (err) return cb(err)
1426 var commitPath = [repo.id, 'commit', commit.id]
1427 cb(null,
1428 'Latest: <strong>' + link(commitPath, commit.title) +
1429 '</strong><br>' +
1430 '<tt>' + commit.id + '</tt><br> ' +
1431 escapeHTML(commit.committer.name) + ' committed on ' +
1432 commit.committer.date.toLocaleString() +
1433 (commit.separateAuthor ? '<br>' +
1434 escapeHTML(commit.author.name) + ' authored on ' +
1435 commit.author.date.toLocaleString() : ''))
1436 })
1437 })
1438 }
1439
1440 // breadcrumbs
1441 function linkPath(basePath, path) {
1442 path = path.slice()
1443 var last = path.pop()
1444 return path.map(function (dir, i) {
1445 return link(basePath.concat(path.slice(0, i+1)), dir)
1446 }).concat(last).join(' / ')
1447 }
1448
1449 function renderRepoTree(repo, rev, path) {
1450 var pathLinks = path.length === 0 ? '' :
1451 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1452 return cat([
1453 pull.once('<h3>Files' + pathLinks + '</h3>'),
1454 pull(
1455 repo.readDir(rev, path),
1456 pull.map(function (file) {
1457 var type = (file.mode === 040000) ? 'tree' :
1458 (file.mode === 0160000) ? 'commit' : 'blob'
1459 if (type == 'commit')
1460 return ['<span title="git commit link">🖈</span>', '<span title="' + escapeHTML(file.id) + '">' + escapeHTML(file.name) + '</span>']
1461 var filePath = [repo.id, type, rev].concat(path, file.name)
1462 return ['<i>' + (type == 'tree' ? '📁' : '📄') + '</i>',
1463 link(filePath, file.name)]
1464 }),
1465 table('class="files"')
1466 )
1467 ])
1468 }
1469
1470 /* Repo readme */
1471
1472 function renderRepoReadme(repo, branch, path) {
1473 return readNext(function (cb) {
1474 pull(
1475 repo.readDir(branch, path),
1476 pull.filter(function (file) {
1477 return /readme(\.|$)/i.test(file.name)
1478 }),
1479 pull.take(1),
1480 pull.collect(function (err, files) {
1481 if (err) return cb(null, pull.empty())
1482 var file = files[0]
1483 if (!file)
1484 return cb(null, pull.once(path.length ? '' : '<p>No readme</p>'))
1485 repo.getObjectFromAny(file.id, function (err, obj) {
1486 if (err) return cb(err)
1487 cb(null, cat([
1488 pull.once('<section><h4><a name="readme">' +
1489 escapeHTML(file.name) + '</a></h4><hr/>'),
1490 renderObjectData(obj, file.name, repo, branch, path),
1491 pull.once('</section>')
1492 ]))
1493 })
1494 })
1495 )
1496 })
1497 }
1498
1499 /* Repo commit */
1500
1501 function serveRepoCommit(repo, rev) {
1502 return renderRepoPage(repo, null, rev, cat([
1503 readNext(function (cb) {
1504 repo.getCommitParsed(rev, function (err, commit) {
1505 if (err) return cb(err)
1506 var commitPath = [repo.id, 'commit', commit.id]
1507 var treePath = [repo.id, 'tree', commit.id]
1508 cb(null, cat([pull.once(
1509 '<h3>' + link(commitPath, 'Commit ' + rev) + '</h3>' +
1510 '<section class="collapse">' +
1511 '<div class="right-bar">' +
1512 link(treePath, 'Browse Files') +
1513 '</div>' +
1514 '<h4>' + escapeHTML(commit.title) + '</h4>' +
1515 (commit.body ? pre(commit.body) : '') +
1516 (commit.separateAuthor ? escapeHTML(commit.author.name) +
1517 ' authored on ' + commit.author.date.toLocaleString() + '<br>'
1518 : '') +
1519 escapeHTML(commit.committer.name) + ' committed on ' +
1520 commit.committer.date.toLocaleString() + '<br/>' +
1521 commit.parents.map(function (id) {
1522 return 'Parent: ' + link([repo.id, 'commit', id], id)
1523 }).join('<br>') +
1524 '</section>' +
1525 '<section><h3>Files changed</h3>'),
1526 // TODO: show diff from all parents (merge commits)
1527 renderDiffStat([repo, repo], [commit.parents[0], commit.id]),
1528 pull.once('</section>')
1529 ]))
1530 })
1531 })
1532 ]))
1533 }
1534
1535 /* Diff stat */
1536
1537 function renderDiffStat(repos, treeIds) {
1538 if (treeIds.length == 0) treeIds = [null]
1539 var id = treeIds[0]
1540 var lastI = treeIds.length - 1
1541 var oldTree = treeIds[0]
1542 var changedFiles = []
1543 return cat([
1544 pull(
1545 Repo.diffTrees(repos, treeIds, true),
1546 pull.map(function (item) {
1547 var filename = escapeHTML(item.filename = item.path.join('/'))
1548 var oldId = item.id && item.id[0]
1549 var newId = item.id && item.id[lastI]
1550 var oldMode = item.mode && item.mode[0]
1551 var newMode = item.mode && item.mode[lastI]
1552 var action =
1553 !oldId && newId ? 'added' :
1554 oldId && !newId ? 'deleted' :
1555 oldMode != newMode ?
1556 'changed mode from ' + oldMode.toString(8) +
1557 ' to ' + newMode.toString(8) :
1558 'changed'
1559 if (item.id)
1560 changedFiles.push(item)
1561 var commitId = item.id[lastI] ? id : treeIds.filter(Boolean)[0]
1562 var blobsPath = treeIds[1]
1563 ? [repos[1].id, 'blob', treeIds[1]]
1564 : [repos[0].id, 'blob', treeIds[0]]
1565 var rawsPath = treeIds[1]
1566 ? [repos[1].id, 'raw', treeIds[1]]
1567 : [repos[0].id, 'raw', treeIds[0]]
1568 item.blobPath = blobsPath.concat(item.path)
1569 item.rawPath = rawsPath.concat(item.path)
1570 var fileHref = item.id ?
1571 '#' + encodeURIComponent(item.path.join('/')) :
1572 encodeLink(item.blobPath)
1573 return ['<a href="' + fileHref + '">' + filename + '</a>', action]
1574 }),
1575 table()
1576 ),
1577 pull(
1578 pull.values(changedFiles),
1579 paramap(function (item, cb) {
1580 var extension = getExtension(item.filename)
1581 if (extension in imgMimes) {
1582 var filename = escapeHTML(item.filename)
1583 return cb(null,
1584 '<pre><table class="code">' +
1585 '<tr><th id="' + escapeHTML(item.filename) + '">' +
1586 filename + '</th></tr>' +
1587 '<tr><td><img src="' + encodeLink(item.rawPath) + '"' +
1588 ' alt="' + filename + '"/></td></tr>' +
1589 '</table></pre>')
1590 }
1591 var done = multicb({ pluck: 1, spread: true })
1592 getRepoObjectString(repos[0], item.id[0], done())
1593 getRepoObjectString(repos[1], item.id[lastI], done())
1594 done(function (err, strOld, strNew) {
1595 if (err) return cb(err)
1596 cb(null, htmlLineDiff(item.filename, item.filename,
1597 strOld, strNew,
1598 encodeLink(item.blobPath)))
1599 })
1600 }, 4)
1601 )
1602 ])
1603 }
1604
1605 function htmlLineDiff(filename, anchor, oldStr, newStr, blobHref) {
1606 var diff = JsDiff.structuredPatch('', '', oldStr, newStr)
1607 var groups = diff.hunks.map(function (hunk) {
1608 var oldLine = hunk.oldStart
1609 var newLine = hunk.newStart
1610 var header = '<tr class="diff-hunk-header"><td colspan=2></td><td>' +
1611 '@@ -' + oldLine + ',' + hunk.oldLines + ' ' +
1612 '+' + newLine + ',' + hunk.newLines + ' @@' +
1613 '</td></tr>'
1614 return [header].concat(hunk.lines.map(function (line) {
1615 var s = line[0]
1616 if (s == '\\') return
1617 var html = highlight(line, getExtension(filename))
1618 var trClass = s == '+' ? 'diff-new' : s == '-' ? 'diff-old' : ''
1619 var lineNums = [s == '+' ? '' : oldLine++, s == '-' ? '' : newLine++]
1620 var id = [filename].concat(lineNums).join('-')
1621 return '<tr id="' + escapeHTML(id) + '" class="' + trClass + '">' +
1622 lineNums.map(function (num) {
1623 return '<td class="code-linenum">' +
1624 (num ? '<a href="#' + encodeURIComponent(id) + '">' +
1625 num + '</a>' : '') + '</td>'
1626 }).join('') +
1627 '<td class="code-text">' + html + '</td></tr>'
1628 }))
1629 })
1630 return '<pre><table class="code">' +
1631 '<tr><th colspan=3 id="' + escapeHTML(anchor) + '">' + filename +
1632 '<span class="right-bar">' +
1633 '<a href="' + blobHref + '">View</a> ' +
1634 '</span></th></tr>' +
1635 [].concat.apply([], groups).join('') +
1636 '</table></pre>'
1637 }
1638
1639 /* An unknown message linking to a repo */
1640
1641 function serveRepoSomething(req, repo, id, msg, path) {
1642 return renderRepoPage(repo, null, null,
1643 pull.once('<section><h3>' + link([id]) + '</h3>' +
1644 json(msg) + '</section>'))
1645 }
1646
1647 /* Repo update */
1648
1649 function objsArr(objs) {
1650 return Array.isArray(objs) ? objs :
1651 Object.keys(objs).map(function (sha1) {
1652 var obj = Object.create(objs[sha1])
1653 obj.sha1 = sha1
1654 return obj
1655 })
1656 }
1657
1658 function serveRepoUpdate(req, repo, id, msg, path) {
1659 var raw = req._u.query.raw != null
1660
1661 if (raw)
1662 return renderRepoPage(repo, 'activity', null, pull.once(
1663 '<a href="?" class="raw-link header-align">Info</a>' +
1664 '<h3>Update</h3>' +
1665 '<section class="collapse">' + json({key: id, value: msg}) + '</section>'))
1666
1667 // convert packs to old single-object style
1668 if (msg.content.indexes) {
1669 for (var i = 0; i < msg.content.indexes.length; i++) {
1670 msg.content.packs[i] = {
1671 pack: {link: msg.content.packs[i].link},
1672 idx: msg.content.indexes[i]
1673 }
1674 }
1675 }
1676
1677 return renderRepoPage(repo, 'activity', null, cat([
1678 pull.once(
1679 '<a href="?raw" class="raw-link header-align">Data</a>' +
1680 '<h3>Update</h3>' +
1681 renderRepoUpdate(repo, {key: id, value: msg}, true) +
1682 (msg.content.objects ? '<h3>Objects</h3>' +
1683 objsArr(msg.content.objects).map(renderObject).join('\n') : '') +
1684 (msg.content.packs ? '<h3>Packs</h3>' +
1685 msg.content.packs.map(renderPack).join('\n') : '')),
1686 cat(!msg.content.packs ? [] : [
1687 pull.once('<h3>Commits</h3>'),
1688 pull(
1689 pull.values(msg.content.packs),
1690 pull.asyncMap(function (pack, cb) {
1691 var done = multicb({ pluck: 1, spread: true })
1692 getBlob(pack.pack.link, done())
1693 getBlob(pack.idx.link, done())
1694 done(function (err, readPack, readIdx) {
1695 if (err) return cb(renderError(err))
1696 cb(null, gitPack.decodeWithIndex(repo, readPack, readIdx))
1697 })
1698 }),
1699 pull.flatten(),
1700 pull.asyncMap(function (obj, cb) {
1701 if (obj.type == 'commit')
1702 Repo.getCommitParsed(obj, cb)
1703 else
1704 pull(obj.read, pull.drain(null, cb))
1705 }),
1706 pull.filter(),
1707 pull.map(function (commit) {
1708 return renderCommit(repo, commit)
1709 })
1710 )
1711 ])
1712 ]))
1713 }
1714
1715 function renderObject(obj) {
1716 return '<section class="collapse">' +
1717 obj.type + ' ' + link([obj.link], obj.sha1) + '<br>' +
1718 obj.length + ' bytes' +
1719 '</section>'
1720 }
1721
1722 function renderPack(info) {
1723 return '<section class="collapse">' +
1724 (info.pack ? 'Pack: ' + link([info.pack.link]) + '<br>' : '') +
1725 (info.idx ? 'Index: ' + link([info.idx.link]) : '') + '</section>'
1726 }
1727
1728 /* Blob */
1729
1730 function serveRepoBlob(repo, rev, path) {
1731 return readNext(function (cb) {
1732 repo.getFile(rev, path, function (err, object) {
1733 if (err) return cb(null, serveBlobNotFound(repo.id, err))
1734 var type = repo.isCommitHash(rev) ? 'Tree' : 'Branch'
1735 var pathLinks = path.length === 0 ? '' :
1736 ': ' + linkPath([repo.id, 'tree'], [rev].concat(path))
1737 var rawFilePath = [repo.id, 'raw', rev].concat(path)
1738 var dirPath = path.slice(0, path.length-1)
1739 var filename = path[path.length-1]
1740 var extension = getExtension(filename)
1741 cb(null, renderRepoPage(repo, 'code', rev, cat([
1742 pull.once('<section><form action="" method="get">' +
1743 '<h3>' + type + ': ' + rev + ' '),
1744 revMenu(repo, rev),
1745 pull.once('</h3></form>'),
1746 type == 'Branch' && renderRepoLatest(repo, rev),
1747 pull.once('</section><section class="collapse">' +
1748 '<h3>Files' + pathLinks + '</h3>' +
1749 '<div>' + object.length + ' bytes' +
1750 '<span class="raw-link">' + link(rawFilePath, 'Raw') + '</span>' +
1751 '</div></section>' +
1752 '<section>'),
1753 extension in imgMimes
1754 ? pull.once('<img src="' + encodeLink(rawFilePath) +
1755 '" alt="' + escapeHTML(filename) + '" />')
1756 : renderObjectData(object, filename, repo, rev, dirPath),
1757 pull.once('</section>')
1758 ])))
1759 })
1760 })
1761 }
1762
1763 function serveBlobNotFound(repoId, err) {
1764 return serveTemplate('Blob not found', 404)(pull.values([
1765 '<h2>Blob not found</h2>',
1766 '<p>Blob in repo ' + link([repoId]) + ' was not found</p>',
1767 '<pre>' + escapeHTML(err.stack) + '</pre>'
1768 ]))
1769 }
1770
1771 /* Raw blob */
1772
1773 function serveRepoRaw(repo, branch, path) {
1774 return readNext(function (cb) {
1775 repo.getFile(branch, path, function (err, object) {
1776 if (err) return cb(null, serveBuffer(404, 'Blob not found'))
1777 var extension = getExtension(path[path.length-1])
1778 var contentType = imgMimes[extension]
1779 cb(null, pull(object.read, serveRaw(object.length, contentType)))
1780 })
1781 })
1782 }
1783
1784 function serveRaw(length, contentType) {
1785 var inBody
1786 var headers = {
1787 'Content-Type': contentType || 'text/plain; charset=utf-8',
1788 'Cache-Control': 'max-age=31536000'
1789 }
1790 if (length != null)
1791 headers['Content-Length'] = length
1792 return function (read) {
1793 return function (end, cb) {
1794 if (inBody) return read(end, cb)
1795 if (end) return cb(true)
1796 cb(null, [200, headers])
1797 inBody = true
1798 }
1799 }
1800 }
1801
1802 function getBlob(key, cb) {
1803 ssb.blobs.want(key, function (err, got) {
1804 if (err) cb(err)
1805 else if (!got) cb(new Error('Missing blob ' + key))
1806 else cb(null, ssb.blobs.get(key))
1807 })
1808 }
1809
1810 function serveBlob(req, key) {
1811 getBlob(key, function (err, read) {
1812 if (err) cb(null, serveError(err))
1813 else if (!got) cb(null, serve404(req))
1814 else cb(null, serveRaw()(read))
1815 })
1816 }
1817
1818 /* Digs */
1819
1820 function serveRepoDigs(repo) {
1821 return readNext(function (cb) {
1822 getVotes(repo.id, function (err, votes) {
1823 cb(null, renderRepoPage(repo, null, null, cat([
1824 pull.once('<section><h3>Digs</h3>' +
1825 '<div>Total: ' + votes.upvotes + '</div>'),
1826 pull(
1827 pull.values(Object.keys(votes.upvoters)),
1828 paramap(function (feedId, cb) {
1829 about.getName(feedId, function (err, name) {
1830 if (err) return cb(err)
1831 cb(null, link([feedId], name))
1832 })
1833 }, 8),
1834 ul()
1835 ),
1836 pull.once('</section>')
1837 ])))
1838 })
1839 })
1840 }
1841
1842 /* Forks */
1843
1844 function getForks(repo, includeSelf) {
1845 return pull(
1846 cat([
1847 includeSelf && readOnce(function (cb) {
1848 getMsg(repo.id, function (err, value) {
1849 cb(err, value && {key: repo.id, value: value})
1850 })
1851 }),
1852 ssb.links({
1853 dest: repo.id,
1854 values: true,
1855 rel: 'upstream'
1856 })
1857 ]),
1858 pull.filter(function (msg) {
1859 return msg.value.content && msg.value.content.type == 'git-repo'
1860 }),
1861 paramap(function (msg, cb) {
1862 getRepoFullName(about, msg.value.author, msg.key,
1863 function (err, repoName, authorName) {
1864 if (err) return cb(err)
1865 cb(null, {
1866 key: msg.key,
1867 value: msg.value,
1868 repoName: repoName,
1869 authorName: authorName
1870 })
1871 })
1872 }, 8)
1873 )
1874 }
1875
1876 function serveRepoForks(repo) {
1877 var hasForks
1878 return renderRepoPage(repo, null, null, cat([
1879 pull.once('<h3>Forks</h3>'),
1880 pull(
1881 getForks(repo),
1882 pull.map(function (msg) {
1883 hasForks = true
1884 return '<section class="collapse">' +
1885 link([msg.value.author], msg.authorName) + ' / ' +
1886 link([msg.key], msg.repoName) +
1887 '<span class="right-bar">' +
1888 timestamp(msg.value.timestamp) +
1889 '</span></section>'
1890 })
1891 ),
1892 readOnce(function (cb) {
1893 cb(null, hasForks ? '' : 'No forks')
1894 })
1895 ]))
1896 }
1897
1898 /* Issues */
1899
1900 function serveRepoIssues(req, repo, path) {
1901 var numIssues = 0
1902 var state = req._u.query.state || 'open'
1903 return renderRepoPage(repo, 'issues', null, cat([
1904 pull.once(
1905 (isPublic ? '' :
1906 '<div class="right-bar">' + link([repo.id, 'issues', 'new'],
1907 '<button class="btn">&plus; New Issue</button>', true) +
1908 '</div>') +
1909 '<h3>Issues</h3>' +
1910 nav([
1911 ['?state=open', 'Open', 'open'],
1912 ['?state=closed', 'Closed', 'closed'],
1913 ['?state=all', 'All', 'all']
1914 ], state)),
1915 pull(
1916 issues.createFeedStream({ project: repo.id }),
1917 pull.filter(function (issue) {
1918 return state == 'all' ? true : (state == 'closed') == !issue.open
1919 }),
1920 pull.map(function (issue) {
1921 numIssues++
1922 var state = (issue.open ? 'open' : 'closed')
1923 return '<section class="collapse">' +
1924 '<i class="issue-state issue-state-' + state + '"' +
1925 ' title="' + ucfirst(state) + '">◼</i> ' +
1926 '<a href="' + encodeLink(issue.id) + '">' +
1927 escapeHTML(issue.title) +
1928 '<span class="right-bar">' +
1929 new Date(issue.created_at).toLocaleString() +
1930 '</span>' +
1931 '</a>' +
1932 '</section>'
1933 })
1934 ),
1935 readOnce(function (cb) {
1936 cb(null, numIssues > 0 ? '' : '<p>No issues</p>')
1937 })
1938 ]))
1939 }
1940
1941 /* Pull Requests */
1942
1943 function serveRepoPullReqs(req, repo) {
1944 var count = 0
1945 var state = req._u.query.state || 'open'
1946 return renderRepoPage(repo, 'pulls', null, cat([
1947 pull.once(
1948 (isPublic ? '' :
1949 '<div class="right-bar">' + link([repo.id, 'compare'],
1950 '<button class="btn">&plus; New Pull Request</button>', true) +
1951 '</div>') +
1952 '<h3>Pull Requests</h3>' +
1953 nav([
1954 ['?', 'Open', 'open'],
1955 ['?state=closed', 'Closed', 'closed'],
1956 ['?state=all', 'All', 'all']
1957 ], state)),
1958 pull(
1959 pullReqs.list({
1960 repo: repo.id,
1961 open: {open: true, closed: false}[state]
1962 }),
1963 pull.map(function (issue) {
1964 count++
1965 var state = (issue.open ? 'open' : 'closed')
1966 return '<section class="collapse">' +
1967 '<i class="issue-state issue-state-' + state + '"' +
1968 ' title="' + ucfirst(state) + '">◼</i> ' +
1969 '<a href="' + encodeLink(issue.id) + '">' +
1970 escapeHTML(issue.title) +
1971 '<span class="right-bar">' +
1972 new Date(issue.created_at).toLocaleString() +
1973 '</span>' +
1974 '</a>' +
1975 '</section>'
1976 })
1977 ),
1978 readOnce(function (cb) {
1979 cb(null, count > 0 ? '' : '<p>No pull requests</p>')
1980 })
1981 ]))
1982 }
1983
1984 /* New Issue */
1985
1986 function serveRepoNewIssue(repo, issueId, path) {
1987 return renderRepoPage(repo, 'issues', null, pull.once(
1988 '<h3>New Issue</h3>' +
1989 '<section><form action="" method="post">' +
1990 '<input type="hidden" name="action" value="new-issue">' +
1991 '<p><input class="wide-input" name="title" placeholder="Issue Title" size="77" /></p>' +
1992 renderPostForm(repo, 'Description', 8) +
1993 '<button type="submit" class="btn">Create</button>' +
1994 '</form></section>'))
1995 }
1996
1997 /* Issue */
1998
1999 function serveRepoIssue(req, repo, issue, path, postId) {
2000 var isAuthor = (myId == issue.author) || (myId == repo.feed)
2001 var newestMsg = {key: issue.id, value: {timestamp: issue.created_at}}
2002 return renderRepoPage(repo, 'issues', null, cat([
2003 pull.once(
2004 renderNameForm(!isPublic, issue.id, issue.title, 'issue-title', null,
2005 'Rename the issue',
2006 '<h3>' + link([issue.id], issue.title) + '</h3>') +
2007 '<code>' + issue.id + '</code>' +
2008 '<section class="collapse">' +
2009 (issue.open
2010 ? '<strong class="issue-status open">Open</strong>'
2011 : '<strong class="issue-status closed">Closed</strong>')),
2012 readOnce(function (cb) {
2013 about.getName(issue.author, function (err, authorName) {
2014 if (err) return cb(err)
2015 var authorLink = link([issue.author], authorName)
2016 cb(null, authorLink + ' opened this issue on ' +
2017 timestamp(issue.created_at))
2018 })
2019 }),
2020 pull.once('<hr/>' + markdown(issue.text, repo) + '</section>'),
2021 // render posts and edits
2022 pull(
2023 ssb.links({
2024 dest: issue.id,
2025 values: true
2026 }),
2027 pull.unique('key'),
2028 addAuthorName(about),
2029 sortMsgs(),
2030 pull.through(function (msg) {
2031 if (msg.value.timestamp > newestMsg.value.timestamp)
2032 newestMsg = msg
2033 }),
2034 pull.map(renderIssueActivityMsg.bind(null, repo, issue,
2035 'issue', postId))
2036 ),
2037 isPublic ? pull.empty() : readOnce(function (cb) {
2038 cb(null, renderIssueCommentForm(issue, repo, newestMsg.key, isAuthor,
2039 'issue'))
2040 })
2041 ]))
2042 }
2043
2044 function renderIssueActivityMsg(repo, issue, type, postId, msg) {
2045 var authorLink = link([msg.value.author], msg.authorName)
2046 var msgHref = encodeLink(msg.key) + '#' + encodeURIComponent(msg.key)
2047 var msgTimeLink = '<a href="' + msgHref + '"' +
2048 ' name="' + escapeHTML(msg.key) + '">' +
2049 escapeHTML(new Date(msg.value.timestamp).toLocaleString()) + '</a>'
2050 var c = msg.value.content
2051 switch (c.type) {
2052 case 'post':
2053 if (c.root == issue.id) {
2054 var changed = issues.isStatusChanged(msg, issue)
2055 return '<section class="collapse">' +
2056 (msg.key == postId ? '<div class="highlight">' : '') +
2057 '<tt class="right-bar item-id">' + msg.key + '</tt>' +
2058 authorLink +
2059 (changed == null ? '' : ' ' + (
2060 changed ? 'reopened this ' : 'closed this ') + type) +
2061 ' &middot; ' + msgTimeLink +
2062 (msg.key == postId ? '</div>' : '') +
2063 markdown(c.text, repo) +
2064 '</section>'
2065 } else {
2066 var text = c.text || (c.type + ' ' + msg.key)
2067 return '<section class="collapse mention-preview">' +
2068 authorLink + ' mentioned this issue in ' +
2069 '<a href="/' + msg.key + '#' + msg.key + '">' +
2070 String(text).substr(0, 140) + '</a>' +
2071 '</section>'
2072 }
2073 case 'issue':
2074 case 'pull-request':
2075 return '<section class="collapse mention-preview">' +
2076 authorLink + ' mentioned this ' + type + ' in ' +
2077 link([msg.key], String(c.title || msg.key).substr(0, 140)) +
2078 '</section>'
2079 case 'issue-edit':
2080 return '<section class="collapse">' +
2081 (msg.key == postId ? '<div class="highlight">' : '') +
2082 (c.title == null ? '' :
2083 authorLink + ' renamed this ' + type + ' to <q>' +
2084 escapeHTML(c.title) + '</q>') +
2085 ' &middot; ' + msgTimeLink +
2086 (msg.key == postId ? '</div>' : '') +
2087 '</section>'
2088 case 'git-update':
2089 var mention = issues.getMention(msg, issue)
2090 if (mention) {
2091 var commitLink = link([repo.id, 'commit', mention.object],
2092 mention.label || mention.object)
2093 return '<section class="collapse">' +
2094 authorLink + ' ' +
2095 (mention.open ? 'reopened this ' :
2096 'closed this ') + type +
2097 ' &middot; ' + msgTimeLink + '<br/>' +
2098 commitLink +
2099 '</section>'
2100 } else if ((mention = getMention(msg, issue.id))) {
2101 var commitLink = link(mention.object ?
2102 [repo.id, 'commit', mention.object] : [msg.key],
2103 mention.label || mention.object || msg.key)
2104 return '<section class="collapse">' +
2105 authorLink + ' mentioned this ' + type +
2106 ' &middot; ' + msgTimeLink + '<br/>' +
2107 commitLink +
2108 '</section>'
2109 } else {
2110 // fallthrough
2111 }
2112
2113 default:
2114 return '<section class="collapse">' +
2115 authorLink +
2116 ' &middot; ' + msgTimeLink +
2117 json(c) +
2118 '</section>'
2119 }
2120 }
2121
2122 function renderIssueCommentForm(issue, repo, branch, isAuthor, type) {
2123 return '<section><form action="" method="post">' +
2124 '<input type="hidden" name="action" value="comment">' +
2125 '<input type="hidden" name="id" value="' + issue.id + '">' +
2126 '<input type="hidden" name="issue" value="' + issue.id + '">' +
2127 '<input type="hidden" name="repo" value="' + repo.id + '">' +
2128 '<input type="hidden" name="branch" value="' + branch + '">' +
2129 renderPostForm(repo) +
2130 '<input type="submit" class="btn open" value="Comment" />' +
2131 (isAuthor ?
2132 '<input type="submit" class="btn"' +
2133 ' name="' + (issue.open ? 'close' : 'open') + '"' +
2134 ' value="' + (issue.open ? 'Close ' : 'Reopen ') + type + '"' +
2135 '/>' : '') +
2136 '</form></section>'
2137 }
2138
2139 /* Pull Request */
2140
2141 function serveRepoPullReq(req, repo, pr, path, postId) {
2142 var headRepo, authorLink
2143 var page = path[0] || 'activity'
2144 return renderRepoPage(repo, 'pulls', null, cat([
2145 pull.once('<div class="pull-request">' +
2146 renderNameForm(!isPublic, pr.id, pr.title, 'issue-title', null,
2147 'Rename the pull request',
2148 '<h3>' + link([pr.id], pr.title) + '</h3>') +
2149 '<code>' + pr.id + '</code>'),
2150 readOnce(function (cb) {
2151 var done = multicb({ pluck: 1, spread: true })
2152 about.getName(pr.author, done())
2153 var sameRepo = (pr.headRepo == pr.baseRepo)
2154 getRepo(pr.headRepo, function (err, headRepo) {
2155 if (err) return cb(err)
2156 done()(null, headRepo)
2157 getRepoName(about, headRepo.feed, headRepo.id, done())
2158 about.getName(headRepo.feed, done())
2159 })
2160
2161 done(function (err, issueAuthorName, _headRepo,
2162 headRepoName, headRepoAuthorName) {
2163 if (err) return cb(err)
2164 headRepo = _headRepo
2165 authorLink = link([pr.author], issueAuthorName)
2166 var repoLink = link([pr.headRepo], headRepoName)
2167 var headRepoAuthorLink = link([headRepo.feed], headRepoAuthorName)
2168 var headRepoLink = link([headRepo.id], headRepoName)
2169 var headBranchLink = link([headRepo.id, 'tree', pr.headBranch])
2170 var baseBranchLink = link([repo.id, 'tree', pr.baseBranch])
2171 cb(null, '<section class="collapse">' +
2172 (pr.open
2173 ? '<strong class="issue-status open">Open</strong>'
2174 : '<strong class="issue-status closed">Closed</strong>') +
2175 authorLink + ' wants to merge commits into ' +
2176 '<code>' + baseBranchLink + '</code> from ' +
2177 (sameRepo ? '<code>' + headBranchLink + '</code>' :
2178 '<code class="bgslash">' +
2179 headRepoAuthorLink + ' / ' +
2180 headRepoLink + ' / ' +
2181 headBranchLink + '</code>') +
2182 '</section>')
2183 })
2184 }),
2185 pull.once(
2186 nav([
2187 [[pr.id], 'Discussion', 'activity'],
2188 [[pr.id, 'commits'], 'Commits', 'commits'],
2189 [[pr.id, 'files'], 'Files', 'files']
2190 ], page)),
2191 readNext(function (cb) {
2192 if (page == 'commits') cb(null,
2193 renderPullReqCommits(pr, repo, headRepo))
2194 else if (page == 'files') cb(null,
2195 renderPullReqFiles(pr, repo, headRepo))
2196 else cb(null,
2197 renderPullReqActivity(pr, repo, headRepo, authorLink, postId))
2198 })
2199 ]))
2200 }
2201
2202 function renderPullReqCommits(pr, baseRepo, headRepo) {
2203 return cat([
2204 pull.once('<section>'),
2205 renderCommitLog(baseRepo, pr.baseBranch, headRepo, pr.headBranch),
2206 pull.once('</section>')
2207 ])
2208 }
2209
2210 function renderPullReqFiles(pr, baseRepo, headRepo) {
2211 return cat([
2212 pull.once('<section>'),
2213 renderDiffStat([baseRepo, headRepo], [pr.baseBranch, pr.headBranch]),
2214 pull.once('</section>')
2215 ])
2216 }
2217
2218 function renderPullReqActivity(pr, repo, headRepo, authorLink, postId) {
2219 var msgTimeLink = link([pr.id], new Date(pr.created_at).toLocaleString())
2220 var newestMsg = {key: pr.id, value: {timestamp: pr.created_at}}
2221 var isAuthor = (myId == pr.author) || (myId == repo.feed)
2222 return cat([
2223 readOnce(function (cb) {
2224 cb(null,
2225 '<section class="collapse">' +
2226 authorLink + ' &middot; ' + msgTimeLink +
2227 markdown(pr.text, repo) + '</section>')
2228 }),
2229 // render posts, edits, and updates
2230 pull(
2231 many([
2232 ssb.links({
2233 dest: pr.id,
2234 values: true
2235 }),
2236 readNext(function (cb) {
2237 cb(null, pull(
2238 ssb.links({
2239 dest: headRepo.id,
2240 source: headRepo.feed,
2241 rel: 'repo',
2242 values: true,
2243 reverse: true
2244 }),
2245 pull.take(function (link) {
2246 return link.value.timestamp > pr.created_at
2247 }),
2248 pull.filter(function (link) {
2249 return link.value.content.type == 'git-update'
2250 && ('refs/heads/' + pr.headBranch) in link.value.content.refs
2251 })
2252 ))
2253 })
2254 ]),
2255 addAuthorName(about),
2256 pull.unique('key'),
2257 pull.through(function (msg) {
2258 if (msg.value.timestamp > newestMsg.value.timestamp)
2259 newestMsg = msg
2260 }),
2261 sortMsgs(),
2262 pull.map(function (item) {
2263 if (item.value.content.type == 'git-update')
2264 return renderBranchUpdate(pr, item)
2265 return renderIssueActivityMsg(repo, pr,
2266 'pull request', postId, item)
2267 })
2268 ),
2269 !isPublic && isAuthor && pull.once(
2270 '<section class="merge-instructions">' +
2271 '<input type="checkbox" class="toggle" id="merge-instructions"/>' +
2272 '<h4><label for="merge-instructions" class="toggle-link"><a>' +
2273 'Merge via command line…' +
2274 '</a></label></h4>' +
2275 '<div class="contents">' +
2276 '<p>Check out the branch and test the changes:</p>' +
2277 '<pre>' +
2278 'git fetch ssb://' + escapeHTML(pr.headRepo) + ' ' +
2279 escapeHTML(pr.headBranch) + '\n' +
2280 'git checkout -b ' + escapeHTML(pr.headBranch) + ' FETCH_HEAD' +
2281 '</pre>' +
2282 '<p>Merge the changes and push to update the base branch:</p>' +
2283 '<pre>' +
2284 'git checkout ' + escapeHTML(pr.baseBranch) + '\n' +
2285 'git merge ' + escapeHTML(pr.headBranch) + '\n' +
2286 'git push ssb ' + escapeHTML(pr.baseBranch) +
2287 '</pre>' +
2288 '</div></section>'),
2289 !isPublic && readOnce(function (cb) {
2290 cb(null, renderIssueCommentForm(pr, repo, newestMsg.key, isAuthor,
2291 'pull request'))
2292 })
2293 ])
2294 }
2295
2296 function renderBranchUpdate(pr, msg) {
2297 var authorLink = link([msg.value.author], msg.authorName)
2298 var msgLink = link([msg.key],
2299 new Date(msg.value.timestamp).toLocaleString())
2300 var rev = msg.value.content.refs['refs/heads/' + pr.headBranch]
2301 if (!rev)
2302 return '<section class="collapse">' +
2303 authorLink + ' deleted the <code>' + pr.headBranch + '</code> branch' +
2304 ' &middot; ' + msgLink +
2305 '</section>'
2306
2307 var revLink = link([pr.headRepo, 'commit', rev], rev.substr(0, 8))
2308 return '<section class="collapse">' +
2309 authorLink + ' updated the branch to <code>' + revLink + '</code>' +
2310 ' &middot; ' + msgLink +
2311 '</section>'
2312 }
2313
2314 /* Compare changes */
2315
2316 function serveRepoCompare(req, repo) {
2317 var query = req._u.query
2318 var base
2319 var count = 0
2320
2321 return renderRepoPage(repo, 'pulls', null, cat([
2322 pull.once('<h3>Compare changes</h3>' +
2323 '<form action="' + encodeLink(repo.id) + '/comparing" method="get">' +
2324 '<section>'),
2325 pull.once('Base branch: '),
2326 readNext(function (cb) {
2327 if (query.base) gotBase(null, query.base)
2328 else repo.getSymRef('HEAD', true, gotBase)
2329 function gotBase(err, ref) {
2330 if (err) return cb(err)
2331 cb(null, branchMenu(repo, 'base', base = ref || 'HEAD'))
2332 }
2333 }),
2334 pull.once('<br/>Comparison repo/branch:'),
2335 pull(
2336 getForks(repo, true),
2337 pull.asyncMap(function (msg, cb) {
2338 getRepo(msg.key, function (err, repo) {
2339 if (err) return cb(err)
2340 cb(null, {
2341 msg: msg,
2342 repo: repo
2343 })
2344 })
2345 }),
2346 pull.map(renderFork),
2347 pull.flatten()
2348 ),
2349 pull.once('</section>'),
2350 readOnce(function (cb) {
2351 cb(null, count == 0 ? 'No branches to compare!' :
2352 '<button type="submit" class="btn">Compare</button>')
2353 }),
2354 pull.once('</form>')
2355 ]))
2356
2357 function renderFork(fork) {
2358 return pull(
2359 fork.repo.refs(),
2360 pull.map(function (ref) {
2361 var m = /^refs\/([^\/]*)\/(.*)$/.exec(ref.name) || [,ref.name]
2362 return {
2363 type: m[1],
2364 name: m[2],
2365 value: ref.value
2366 }
2367 }),
2368 pull.filter(function (ref) {
2369 return ref.type == 'heads'
2370 && !(ref.name == base && fork.msg.key == repo.id)
2371 }),
2372 pull.map(function (ref) {
2373 var branchLink = link([fork.msg.key, 'tree', ref.name], ref.name)
2374 var authorLink = link([fork.msg.value.author], fork.msg.authorName)
2375 var repoLink = link([fork.msg.key], fork.msg.repoName)
2376 var value = fork.msg.key + ':' + ref.name
2377 count++
2378 return '<div class="bgslash">' +
2379 '<input type="radio" name="head"' +
2380 ' value="' + escapeHTML(value) + '"' +
2381 (query.head == value ? ' checked="checked"' : '') + '> ' +
2382 authorLink + ' / ' + repoLink + ' / ' + branchLink + '</div>'
2383 })
2384 )
2385 }
2386 }
2387
2388 function serveRepoComparing(req, repo) {
2389 var query = req._u.query
2390 var baseBranch = query.base
2391 var s = (query.head || '').split(':')
2392
2393 if (!s || !baseBranch)
2394 return serveRedirect(encodeLink([repo.id, 'compare']))
2395
2396 var headRepoId = s[0]
2397 var headBranch = s[1]
2398 var baseLink = link([repo.id, 'tree', baseBranch])
2399 var headBranchLink = link([headRepoId, 'tree', headBranch])
2400 var backHref = encodeLink([repo.id, 'compare']) + req._u.search
2401
2402 return renderRepoPage(repo, 'pulls', null, cat([
2403 pull.once('<h3>' +
2404 (query.expand ? 'Open a pull request' : 'Comparing changes') +
2405 '</h3>'),
2406 readNext(function (cb) {
2407 getRepo(headRepoId, function (err, headRepo) {
2408 if (err) return cb(err)
2409 getRepoFullName(about, headRepo.feed, headRepo.id,
2410 function (err, repoName, authorName) {
2411 if (err) return cb(err)
2412 cb(null, renderRepoInfo(Repo(headRepo), repoName, authorName))
2413 }
2414 )
2415 })
2416 })
2417 ]))
2418
2419 function renderRepoInfo(headRepo, headRepoName, headRepoAuthorName) {
2420 var authorLink = link([headRepo.feed], headRepoAuthorName)
2421 var repoLink = link([headRepoId], headRepoName)
2422 return cat([
2423 pull.once('<section>' +
2424 'Base: ' + baseLink + '<br/>' +
2425 'Head: <span class="bgslash">' + authorLink + ' / ' + repoLink +
2426 ' / ' + headBranchLink + '</span>' +
2427 '</section>' +
2428 (query.expand ? '<section><form method="post" action="">' +
2429 hiddenInputs({
2430 action: 'new-pull',
2431 branch: baseBranch,
2432 head_repo: headRepoId,
2433 head_branch: headBranch
2434 }) +
2435 '<input class="wide-input" name="title"' +
2436 ' placeholder="Title" size="77"/>' +
2437 renderPostForm(repo, 'Description', 8) +
2438 '<button type="submit" class="btn open">Create</button>' +
2439 '</form></section>'
2440 : '<section><form method="get" action="">' +
2441 hiddenInputs({
2442 base: baseBranch,
2443 head: query.head
2444 }) +
2445 '<button class="btn open" type="submit" name="expand" value="1">' +
2446 '<i>⎇</i> Create pull request</button> ' +
2447 '<a href="' + backHref + '">Back</a>' +
2448 '</form></section>') +
2449 '<div id="commits"></div>' +
2450 '<div class="tab-links">' +
2451 '<a href="#" id="files-link">Files changed</a> ' +
2452 '<a href="#commits" id="commits-link">Commits</a>' +
2453 '</div>' +
2454 '<section id="files-tab">'),
2455 renderDiffStat([repo, headRepo], [baseBranch, headBranch]),
2456 pull.once('</section>' +
2457 '<section id="commits-tab">'),
2458 renderCommitLog(repo, baseBranch, headRepo, headBranch),
2459 pull.once('</section>')
2460 ])
2461 }
2462 }
2463
2464 function renderCommitLog(baseRepo, baseBranch, headRepo, headBranch) {
2465 return cat([
2466 pull.once('<table class="compare-commits">'),
2467 readNext(function (cb) {
2468 baseRepo.resolveRef(baseBranch, function (err, baseBranchRev) {
2469 if (err) return cb(err)
2470 var currentDay
2471 return cb(null, pull(
2472 headRepo.readLog(headBranch),
2473 pull.take(function (rev) { return rev != baseBranchRev }),
2474 pullReverse(),
2475 paramap(headRepo.getCommitParsed.bind(headRepo), 8),
2476 pull.map(function (commit) {
2477 var commitPath = [baseRepo.id, 'commit', commit.id]
2478 var commitIdShort = '<tt>' + commit.id.substr(0, 8) + '</tt>'
2479 var day = Math.floor(commit.author.date / 86400000)
2480 var dateRow = day == currentDay ? '' :
2481 '<tr><th colspan=3 class="date-info">' +
2482 commit.author.date.toLocaleDateString() +
2483 '</th><tr>'
2484 currentDay = day
2485 return dateRow + '<tr>' +
2486 '<td>' + escapeHTML(commit.author.name) + '</td>' +
2487 '<td>' + link(commitPath, commit.title) + '</td>' +
2488 '<td>' + link(commitPath, commitIdShort, true) + '</td>' +
2489 '</tr>'
2490 })
2491 ))
2492 })
2493 }),
2494 pull.once('</table>')
2495 ])
2496 }
2497}
2498

Built with git-ssb-web