git ssb

30+

cel / git-ssb-web



Tree: 4dc39748d2ffa17c933c5d5262e9bdf1c50c13e2

Files: 4dc39748d2ffa17c933c5d5262e9bdf1c50c13e2 / index.js

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