git ssb

30+

cel / git-ssb-web



Tree: 9bc45d19cb1aa7c79e3fb4cf5155fd40bb547445

Files: 9bc45d19cb1aa7c79e3fb4cf5155fd40bb547445 / index.js

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

Built with git-ssb-web