forked from tangled.org/core
this repo has no description

Compare changes

Choose any two refs to compare.

+5047 -1548
+542 -2
api/tangled/cbor_gen.go
··· 504 504 505 505 return nil 506 506 } 507 + func (t *FeedReaction) MarshalCBOR(w io.Writer) error { 508 + if t == nil { 509 + _, err := w.Write(cbg.CborNull) 510 + return err 511 + } 512 + 513 + cw := cbg.NewCborWriter(w) 514 + 515 + if _, err := cw.Write([]byte{164}); err != nil { 516 + return err 517 + } 518 + 519 + // t.LexiconTypeID (string) (string) 520 + if len("$type") > 1000000 { 521 + return xerrors.Errorf("Value in field \"$type\" was too long") 522 + } 523 + 524 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("$type"))); err != nil { 525 + return err 526 + } 527 + if _, err := cw.WriteString(string("$type")); err != nil { 528 + return err 529 + } 530 + 531 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("sh.tangled.feed.reaction"))); err != nil { 532 + return err 533 + } 534 + if _, err := cw.WriteString(string("sh.tangled.feed.reaction")); err != nil { 535 + return err 536 + } 537 + 538 + // t.Subject (string) (string) 539 + if len("subject") > 1000000 { 540 + return xerrors.Errorf("Value in field \"subject\" was too long") 541 + } 542 + 543 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("subject"))); err != nil { 544 + return err 545 + } 546 + if _, err := cw.WriteString(string("subject")); err != nil { 547 + return err 548 + } 549 + 550 + if len(t.Subject) > 1000000 { 551 + return xerrors.Errorf("Value in field t.Subject was too long") 552 + } 553 + 554 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Subject))); err != nil { 555 + return err 556 + } 557 + if _, err := cw.WriteString(string(t.Subject)); err != nil { 558 + return err 559 + } 560 + 561 + // t.Reaction (string) (string) 562 + if len("reaction") > 1000000 { 563 + return xerrors.Errorf("Value in field \"reaction\" was too long") 564 + } 565 + 566 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("reaction"))); err != nil { 567 + return err 568 + } 569 + if _, err := cw.WriteString(string("reaction")); err != nil { 570 + return err 571 + } 572 + 573 + if len(t.Reaction) > 1000000 { 574 + return xerrors.Errorf("Value in field t.Reaction was too long") 575 + } 576 + 577 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Reaction))); err != nil { 578 + return err 579 + } 580 + if _, err := cw.WriteString(string(t.Reaction)); err != nil { 581 + return err 582 + } 583 + 584 + // t.CreatedAt (string) (string) 585 + if len("createdAt") > 1000000 { 586 + return xerrors.Errorf("Value in field \"createdAt\" was too long") 587 + } 588 + 589 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("createdAt"))); err != nil { 590 + return err 591 + } 592 + if _, err := cw.WriteString(string("createdAt")); err != nil { 593 + return err 594 + } 595 + 596 + if len(t.CreatedAt) > 1000000 { 597 + return xerrors.Errorf("Value in field t.CreatedAt was too long") 598 + } 599 + 600 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.CreatedAt))); err != nil { 601 + return err 602 + } 603 + if _, err := cw.WriteString(string(t.CreatedAt)); err != nil { 604 + return err 605 + } 606 + return nil 607 + } 608 + 609 + func (t *FeedReaction) UnmarshalCBOR(r io.Reader) (err error) { 610 + *t = FeedReaction{} 611 + 612 + cr := cbg.NewCborReader(r) 613 + 614 + maj, extra, err := cr.ReadHeader() 615 + if err != nil { 616 + return err 617 + } 618 + defer func() { 619 + if err == io.EOF { 620 + err = io.ErrUnexpectedEOF 621 + } 622 + }() 623 + 624 + if maj != cbg.MajMap { 625 + return fmt.Errorf("cbor input should be of type map") 626 + } 627 + 628 + if extra > cbg.MaxLength { 629 + return fmt.Errorf("FeedReaction: map struct too large (%d)", extra) 630 + } 631 + 632 + n := extra 633 + 634 + nameBuf := make([]byte, 9) 635 + for i := uint64(0); i < n; i++ { 636 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 637 + if err != nil { 638 + return err 639 + } 640 + 641 + if !ok { 642 + // Field doesn't exist on this type, so ignore it 643 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 644 + return err 645 + } 646 + continue 647 + } 648 + 649 + switch string(nameBuf[:nameLen]) { 650 + // t.LexiconTypeID (string) (string) 651 + case "$type": 652 + 653 + { 654 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 655 + if err != nil { 656 + return err 657 + } 658 + 659 + t.LexiconTypeID = string(sval) 660 + } 661 + // t.Subject (string) (string) 662 + case "subject": 663 + 664 + { 665 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 666 + if err != nil { 667 + return err 668 + } 669 + 670 + t.Subject = string(sval) 671 + } 672 + // t.Reaction (string) (string) 673 + case "reaction": 674 + 675 + { 676 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 677 + if err != nil { 678 + return err 679 + } 680 + 681 + t.Reaction = string(sval) 682 + } 683 + // t.CreatedAt (string) (string) 684 + case "createdAt": 685 + 686 + { 687 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 688 + if err != nil { 689 + return err 690 + } 691 + 692 + t.CreatedAt = string(sval) 693 + } 694 + 695 + default: 696 + // Field doesn't exist on this type, so ignore it 697 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 698 + return err 699 + } 700 + } 701 + } 702 + 703 + return nil 704 + } 507 705 func (t *FeedStar) MarshalCBOR(w io.Writer) error { 508 706 if t == nil { 509 707 _, err := w.Write(cbg.CborNull) ··· 1011 1209 } 1012 1210 1013 1211 cw := cbg.NewCborWriter(w) 1212 + fieldCount := 3 1014 1213 1015 - if _, err := cw.Write([]byte{162}); err != nil { 1214 + if t.LangBreakdown == nil { 1215 + fieldCount-- 1216 + } 1217 + 1218 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1016 1219 return err 1017 1220 } 1018 1221 ··· 1047 1250 if err := cbg.WriteBool(w, t.IsDefaultRef); err != nil { 1048 1251 return err 1049 1252 } 1253 + 1254 + // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1255 + if t.LangBreakdown != nil { 1256 + 1257 + if len("langBreakdown") > 1000000 { 1258 + return xerrors.Errorf("Value in field \"langBreakdown\" was too long") 1259 + } 1260 + 1261 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("langBreakdown"))); err != nil { 1262 + return err 1263 + } 1264 + if _, err := cw.WriteString(string("langBreakdown")); err != nil { 1265 + return err 1266 + } 1267 + 1268 + if err := t.LangBreakdown.MarshalCBOR(cw); err != nil { 1269 + return err 1270 + } 1271 + } 1050 1272 return nil 1051 1273 } 1052 1274 ··· 1075 1297 1076 1298 n := extra 1077 1299 1078 - nameBuf := make([]byte, 12) 1300 + nameBuf := make([]byte, 13) 1079 1301 for i := uint64(0); i < n; i++ { 1080 1302 nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1081 1303 if err != nil { ··· 1128 1350 t.IsDefaultRef = true 1129 1351 default: 1130 1352 return fmt.Errorf("booleans are either major type 7, value 20 or 21 (got %d)", extra) 1353 + } 1354 + // t.LangBreakdown (tangled.GitRefUpdate_Meta_LangBreakdown) (struct) 1355 + case "langBreakdown": 1356 + 1357 + { 1358 + 1359 + b, err := cr.ReadByte() 1360 + if err != nil { 1361 + return err 1362 + } 1363 + if b != cbg.CborNull[0] { 1364 + if err := cr.UnreadByte(); err != nil { 1365 + return err 1366 + } 1367 + t.LangBreakdown = new(GitRefUpdate_Meta_LangBreakdown) 1368 + if err := t.LangBreakdown.UnmarshalCBOR(cr); err != nil { 1369 + return xerrors.Errorf("unmarshaling t.LangBreakdown pointer: %w", err) 1370 + } 1371 + } 1372 + 1131 1373 } 1132 1374 1133 1375 default: ··· 1425 1667 } 1426 1668 1427 1669 t.Email = string(sval) 1670 + } 1671 + 1672 + default: 1673 + // Field doesn't exist on this type, so ignore it 1674 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1675 + return err 1676 + } 1677 + } 1678 + } 1679 + 1680 + return nil 1681 + } 1682 + func (t *GitRefUpdate_Meta_LangBreakdown) MarshalCBOR(w io.Writer) error { 1683 + if t == nil { 1684 + _, err := w.Write(cbg.CborNull) 1685 + return err 1686 + } 1687 + 1688 + cw := cbg.NewCborWriter(w) 1689 + fieldCount := 1 1690 + 1691 + if t.Inputs == nil { 1692 + fieldCount-- 1693 + } 1694 + 1695 + if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil { 1696 + return err 1697 + } 1698 + 1699 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1700 + if t.Inputs != nil { 1701 + 1702 + if len("inputs") > 1000000 { 1703 + return xerrors.Errorf("Value in field \"inputs\" was too long") 1704 + } 1705 + 1706 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("inputs"))); err != nil { 1707 + return err 1708 + } 1709 + if _, err := cw.WriteString(string("inputs")); err != nil { 1710 + return err 1711 + } 1712 + 1713 + if len(t.Inputs) > 8192 { 1714 + return xerrors.Errorf("Slice value in field t.Inputs was too long") 1715 + } 1716 + 1717 + if err := cw.WriteMajorTypeHeader(cbg.MajArray, uint64(len(t.Inputs))); err != nil { 1718 + return err 1719 + } 1720 + for _, v := range t.Inputs { 1721 + if err := v.MarshalCBOR(cw); err != nil { 1722 + return err 1723 + } 1724 + 1725 + } 1726 + } 1727 + return nil 1728 + } 1729 + 1730 + func (t *GitRefUpdate_Meta_LangBreakdown) UnmarshalCBOR(r io.Reader) (err error) { 1731 + *t = GitRefUpdate_Meta_LangBreakdown{} 1732 + 1733 + cr := cbg.NewCborReader(r) 1734 + 1735 + maj, extra, err := cr.ReadHeader() 1736 + if err != nil { 1737 + return err 1738 + } 1739 + defer func() { 1740 + if err == io.EOF { 1741 + err = io.ErrUnexpectedEOF 1742 + } 1743 + }() 1744 + 1745 + if maj != cbg.MajMap { 1746 + return fmt.Errorf("cbor input should be of type map") 1747 + } 1748 + 1749 + if extra > cbg.MaxLength { 1750 + return fmt.Errorf("GitRefUpdate_Meta_LangBreakdown: map struct too large (%d)", extra) 1751 + } 1752 + 1753 + n := extra 1754 + 1755 + nameBuf := make([]byte, 6) 1756 + for i := uint64(0); i < n; i++ { 1757 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1758 + if err != nil { 1759 + return err 1760 + } 1761 + 1762 + if !ok { 1763 + // Field doesn't exist on this type, so ignore it 1764 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1765 + return err 1766 + } 1767 + continue 1768 + } 1769 + 1770 + switch string(nameBuf[:nameLen]) { 1771 + // t.Inputs ([]*tangled.GitRefUpdate_Pair) (slice) 1772 + case "inputs": 1773 + 1774 + maj, extra, err = cr.ReadHeader() 1775 + if err != nil { 1776 + return err 1777 + } 1778 + 1779 + if extra > 8192 { 1780 + return fmt.Errorf("t.Inputs: array too large (%d)", extra) 1781 + } 1782 + 1783 + if maj != cbg.MajArray { 1784 + return fmt.Errorf("expected cbor array") 1785 + } 1786 + 1787 + if extra > 0 { 1788 + t.Inputs = make([]*GitRefUpdate_Pair, extra) 1789 + } 1790 + 1791 + for i := 0; i < int(extra); i++ { 1792 + { 1793 + var maj byte 1794 + var extra uint64 1795 + var err error 1796 + _ = maj 1797 + _ = extra 1798 + _ = err 1799 + 1800 + { 1801 + 1802 + b, err := cr.ReadByte() 1803 + if err != nil { 1804 + return err 1805 + } 1806 + if b != cbg.CborNull[0] { 1807 + if err := cr.UnreadByte(); err != nil { 1808 + return err 1809 + } 1810 + t.Inputs[i] = new(GitRefUpdate_Pair) 1811 + if err := t.Inputs[i].UnmarshalCBOR(cr); err != nil { 1812 + return xerrors.Errorf("unmarshaling t.Inputs[i] pointer: %w", err) 1813 + } 1814 + } 1815 + 1816 + } 1817 + 1818 + } 1819 + } 1820 + 1821 + default: 1822 + // Field doesn't exist on this type, so ignore it 1823 + if err := cbg.ScanForLinks(r, func(cid.Cid) {}); err != nil { 1824 + return err 1825 + } 1826 + } 1827 + } 1828 + 1829 + return nil 1830 + } 1831 + func (t *GitRefUpdate_Pair) MarshalCBOR(w io.Writer) error { 1832 + if t == nil { 1833 + _, err := w.Write(cbg.CborNull) 1834 + return err 1835 + } 1836 + 1837 + cw := cbg.NewCborWriter(w) 1838 + 1839 + if _, err := cw.Write([]byte{162}); err != nil { 1840 + return err 1841 + } 1842 + 1843 + // t.Lang (string) (string) 1844 + if len("lang") > 1000000 { 1845 + return xerrors.Errorf("Value in field \"lang\" was too long") 1846 + } 1847 + 1848 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("lang"))); err != nil { 1849 + return err 1850 + } 1851 + if _, err := cw.WriteString(string("lang")); err != nil { 1852 + return err 1853 + } 1854 + 1855 + if len(t.Lang) > 1000000 { 1856 + return xerrors.Errorf("Value in field t.Lang was too long") 1857 + } 1858 + 1859 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Lang))); err != nil { 1860 + return err 1861 + } 1862 + if _, err := cw.WriteString(string(t.Lang)); err != nil { 1863 + return err 1864 + } 1865 + 1866 + // t.Size (int64) (int64) 1867 + if len("size") > 1000000 { 1868 + return xerrors.Errorf("Value in field \"size\" was too long") 1869 + } 1870 + 1871 + if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("size"))); err != nil { 1872 + return err 1873 + } 1874 + if _, err := cw.WriteString(string("size")); err != nil { 1875 + return err 1876 + } 1877 + 1878 + if t.Size >= 0 { 1879 + if err := cw.WriteMajorTypeHeader(cbg.MajUnsignedInt, uint64(t.Size)); err != nil { 1880 + return err 1881 + } 1882 + } else { 1883 + if err := cw.WriteMajorTypeHeader(cbg.MajNegativeInt, uint64(-t.Size-1)); err != nil { 1884 + return err 1885 + } 1886 + } 1887 + 1888 + return nil 1889 + } 1890 + 1891 + func (t *GitRefUpdate_Pair) UnmarshalCBOR(r io.Reader) (err error) { 1892 + *t = GitRefUpdate_Pair{} 1893 + 1894 + cr := cbg.NewCborReader(r) 1895 + 1896 + maj, extra, err := cr.ReadHeader() 1897 + if err != nil { 1898 + return err 1899 + } 1900 + defer func() { 1901 + if err == io.EOF { 1902 + err = io.ErrUnexpectedEOF 1903 + } 1904 + }() 1905 + 1906 + if maj != cbg.MajMap { 1907 + return fmt.Errorf("cbor input should be of type map") 1908 + } 1909 + 1910 + if extra > cbg.MaxLength { 1911 + return fmt.Errorf("GitRefUpdate_Pair: map struct too large (%d)", extra) 1912 + } 1913 + 1914 + n := extra 1915 + 1916 + nameBuf := make([]byte, 4) 1917 + for i := uint64(0); i < n; i++ { 1918 + nameLen, ok, err := cbg.ReadFullStringIntoBuf(cr, nameBuf, 1000000) 1919 + if err != nil { 1920 + return err 1921 + } 1922 + 1923 + if !ok { 1924 + // Field doesn't exist on this type, so ignore it 1925 + if err := cbg.ScanForLinks(cr, func(cid.Cid) {}); err != nil { 1926 + return err 1927 + } 1928 + continue 1929 + } 1930 + 1931 + switch string(nameBuf[:nameLen]) { 1932 + // t.Lang (string) (string) 1933 + case "lang": 1934 + 1935 + { 1936 + sval, err := cbg.ReadStringWithMax(cr, 1000000) 1937 + if err != nil { 1938 + return err 1939 + } 1940 + 1941 + t.Lang = string(sval) 1942 + } 1943 + // t.Size (int64) (int64) 1944 + case "size": 1945 + { 1946 + maj, extra, err := cr.ReadHeader() 1947 + if err != nil { 1948 + return err 1949 + } 1950 + var extraI int64 1951 + switch maj { 1952 + case cbg.MajUnsignedInt: 1953 + extraI = int64(extra) 1954 + if extraI < 0 { 1955 + return fmt.Errorf("int64 positive overflow") 1956 + } 1957 + case cbg.MajNegativeInt: 1958 + extraI = int64(extra) 1959 + if extraI < 0 { 1960 + return fmt.Errorf("int64 negative overflow") 1961 + } 1962 + extraI = -1 - extraI 1963 + default: 1964 + return fmt.Errorf("wrong type for int64 field: %d", maj) 1965 + } 1966 + 1967 + t.Size = int64(extraI) 1428 1968 } 1429 1969 1430 1970 default:
+24
api/tangled/feedreaction.go
··· 1 + // Code generated by cmd/lexgen (see Makefile's lexgen); DO NOT EDIT. 2 + 3 + package tangled 4 + 5 + // schema: sh.tangled.feed.reaction 6 + 7 + import ( 8 + "github.com/bluesky-social/indigo/lex/util" 9 + ) 10 + 11 + const ( 12 + FeedReactionNSID = "sh.tangled.feed.reaction" 13 + ) 14 + 15 + func init() { 16 + util.RegisterType("sh.tangled.feed.reaction", &FeedReaction{}) 17 + } // 18 + // RECORDTYPE: FeedReaction 19 + type FeedReaction struct { 20 + LexiconTypeID string `json:"$type,const=sh.tangled.feed.reaction" cborgen:"$type,const=sh.tangled.feed.reaction"` 21 + CreatedAt string `json:"createdAt" cborgen:"createdAt"` 22 + Reaction string `json:"reaction" cborgen:"reaction"` 23 + Subject string `json:"subject" cborgen:"subject"` 24 + }
+13 -2
api/tangled/gitrefUpdate.go
··· 34 34 } 35 35 36 36 type GitRefUpdate_Meta struct { 37 - CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 - IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 37 + CommitCount *GitRefUpdate_Meta_CommitCount `json:"commitCount" cborgen:"commitCount"` 38 + IsDefaultRef bool `json:"isDefaultRef" cborgen:"isDefaultRef"` 39 + LangBreakdown *GitRefUpdate_Meta_LangBreakdown `json:"langBreakdown,omitempty" cborgen:"langBreakdown,omitempty"` 39 40 } 40 41 41 42 type GitRefUpdate_Meta_CommitCount struct { ··· 46 47 Count int64 `json:"count" cborgen:"count"` 47 48 Email string `json:"email" cborgen:"email"` 48 49 } 50 + 51 + type GitRefUpdate_Meta_LangBreakdown struct { 52 + Inputs []*GitRefUpdate_Pair `json:"inputs,omitempty" cborgen:"inputs,omitempty"` 53 + } 54 + 55 + // GitRefUpdate_Pair is a "pair" in the sh.tangled.git.refUpdate schema. 56 + type GitRefUpdate_Pair struct { 57 + Lang string `json:"lang" cborgen:"lang"` 58 + Size int64 `json:"size" cborgen:"size"` 59 + }
+26
appview/db/db.go
··· 199 199 unique(starred_by_did, repo_at) 200 200 ); 201 201 202 + create table if not exists reactions ( 203 + id integer primary key autoincrement, 204 + reacted_by_did text not null, 205 + thread_at text not null, 206 + kind text not null, 207 + rkey text not null, 208 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 209 + unique(reacted_by_did, thread_at, kind) 210 + ); 211 + 202 212 create table if not exists emails ( 203 213 id integer primary key autoincrement, 204 214 did text not null, ··· 409 419 foreign key (pipeline_knot, pipeline_rkey) 410 420 references pipelines (knot, rkey) 411 421 on delete cascade 422 + ); 423 + 424 + create table if not exists repo_languages ( 425 + -- identifiers 426 + id integer primary key autoincrement, 427 + 428 + -- repo identifiers 429 + repo_at text not null, 430 + ref text not null, 431 + is_default_ref integer not null default 0, 432 + 433 + -- language breakdown 434 + language text not null, 435 + bytes integer not null check (bytes >= 0), 436 + 437 + unique(repo_at, ref, language) 412 438 ); 413 439 414 440 create table if not exists migrations (
+2 -2
appview/db/issues.go
··· 277 277 } 278 278 279 279 func GetIssueWithComments(e Execer, repoAt syntax.ATURI, issueId int) (*Issue, []Comment, error) { 280 - query := `select owner_did, issue_id, created, title, body, open from issues where repo_at = ? and issue_id = ?` 280 + query := `select owner_did, issue_id, created, title, body, open, issue_at from issues where repo_at = ? and issue_id = ?` 281 281 row := e.QueryRow(query, repoAt, issueId) 282 282 283 283 var issue Issue 284 284 var createdAt string 285 - err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open) 285 + err := row.Scan(&issue.OwnerDid, &issue.IssueId, &createdAt, &issue.Title, &issue.Body, &issue.Open, &issue.IssueAt) 286 286 if err != nil { 287 287 return nil, nil, err 288 288 }
+93
appview/db/language.go
··· 1 + package db 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type RepoLanguage struct { 11 + Id int64 12 + RepoAt syntax.ATURI 13 + Ref string 14 + IsDefaultRef bool 15 + Language string 16 + Bytes int64 17 + } 18 + 19 + func GetRepoLanguages(e Execer, filters ...filter) ([]RepoLanguage, error) { 20 + var conditions []string 21 + var args []any 22 + for _, filter := range filters { 23 + conditions = append(conditions, filter.Condition()) 24 + args = append(args, filter.Arg()...) 25 + } 26 + 27 + whereClause := "" 28 + if conditions != nil { 29 + whereClause = " where " + strings.Join(conditions, " and ") 30 + } 31 + 32 + query := fmt.Sprintf( 33 + `select id, repo_at, ref, is_default_ref, language, bytes from repo_languages %s`, 34 + whereClause, 35 + ) 36 + rows, err := e.Query(query, args...) 37 + 38 + if err != nil { 39 + return nil, fmt.Errorf("failed to execute query: %w ", err) 40 + } 41 + 42 + var langs []RepoLanguage 43 + for rows.Next() { 44 + var rl RepoLanguage 45 + var isDefaultRef int 46 + 47 + err := rows.Scan( 48 + &rl.Id, 49 + &rl.RepoAt, 50 + &rl.Ref, 51 + &isDefaultRef, 52 + &rl.Language, 53 + &rl.Bytes, 54 + ) 55 + if err != nil { 56 + return nil, fmt.Errorf("failed to scan: %w ", err) 57 + } 58 + 59 + if isDefaultRef != 0 { 60 + rl.IsDefaultRef = true 61 + } 62 + 63 + langs = append(langs, rl) 64 + } 65 + if err = rows.Err(); err != nil { 66 + return nil, fmt.Errorf("failed to scan rows: %w ", err) 67 + } 68 + 69 + return langs, nil 70 + } 71 + 72 + func InsertRepoLanguages(e Execer, langs []RepoLanguage) error { 73 + stmt, err := e.Prepare( 74 + "insert or replace into repo_languages (repo_at, ref, is_default_ref, language, bytes) values (?, ?, ?, ?, ?)", 75 + ) 76 + if err != nil { 77 + return err 78 + } 79 + 80 + for _, l := range langs { 81 + isDefaultRef := 0 82 + if l.IsDefaultRef { 83 + isDefaultRef = 1 84 + } 85 + 86 + _, err := stmt.Exec(l.RepoAt, l.Ref, isDefaultRef, l.Language, l.Bytes) 87 + if err != nil { 88 + return err 89 + } 90 + } 91 + 92 + return nil 93 + }
+108
appview/db/profile.go
··· 348 348 return tx.Commit() 349 349 } 350 350 351 + func GetProfiles(e Execer, filters ...filter) ([]Profile, error) { 352 + var conditions []string 353 + var args []any 354 + for _, filter := range filters { 355 + conditions = append(conditions, filter.Condition()) 356 + args = append(args, filter.Arg()...) 357 + } 358 + 359 + whereClause := "" 360 + if conditions != nil { 361 + whereClause = " where " + strings.Join(conditions, " and ") 362 + } 363 + 364 + profilesQuery := fmt.Sprintf( 365 + `select 366 + id, 367 + did, 368 + description, 369 + include_bluesky, 370 + location 371 + from 372 + profile 373 + %s`, 374 + whereClause, 375 + ) 376 + rows, err := e.Query(profilesQuery, args...) 377 + if err != nil { 378 + return nil, err 379 + } 380 + 381 + profileMap := make(map[string]*Profile) 382 + for rows.Next() { 383 + var profile Profile 384 + var includeBluesky int 385 + 386 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 387 + if err != nil { 388 + return nil, err 389 + } 390 + 391 + if includeBluesky != 0 { 392 + profile.IncludeBluesky = true 393 + } 394 + 395 + profileMap[profile.Did] = &profile 396 + } 397 + if err = rows.Err(); err != nil { 398 + return nil, err 399 + } 400 + 401 + // populate profile links 402 + inClause := strings.TrimSuffix(strings.Repeat("?, ", len(profileMap)), ", ") 403 + args = make([]any, len(profileMap)) 404 + i := 0 405 + for did := range profileMap { 406 + args[i] = did 407 + i++ 408 + } 409 + 410 + linksQuery := fmt.Sprintf("select link, did from profile_links where did in (%s)", inClause) 411 + rows, err = e.Query(linksQuery, args...) 412 + if err != nil { 413 + return nil, err 414 + } 415 + idxs := make(map[string]int) 416 + for did := range profileMap { 417 + idxs[did] = 0 418 + } 419 + for rows.Next() { 420 + var link, did string 421 + if err = rows.Scan(&link, &did); err != nil { 422 + return nil, err 423 + } 424 + 425 + idx := idxs[did] 426 + profileMap[did].Links[idx] = link 427 + idxs[did] = idx + 1 428 + } 429 + 430 + pinsQuery := fmt.Sprintf("select at_uri, did from profile_pinned_repositories where did in (%s)", inClause) 431 + rows, err = e.Query(pinsQuery, args...) 432 + if err != nil { 433 + return nil, err 434 + } 435 + idxs = make(map[string]int) 436 + for did := range profileMap { 437 + idxs[did] = 0 438 + } 439 + for rows.Next() { 440 + var link syntax.ATURI 441 + var did string 442 + if err = rows.Scan(&link, &did); err != nil { 443 + return nil, err 444 + } 445 + 446 + idx := idxs[did] 447 + profileMap[did].PinnedRepos[idx] = link 448 + idxs[did] = idx + 1 449 + } 450 + 451 + var profiles []Profile 452 + for _, p := range profileMap { 453 + profiles = append(profiles, *p) 454 + } 455 + 456 + return profiles, nil 457 + } 458 + 351 459 func GetProfile(e Execer, did string) (*Profile, error) { 352 460 var profile Profile 353 461 profile.Did = did
+141
appview/db/reaction.go
··· 1 + package db 2 + 3 + import ( 4 + "log" 5 + "time" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + ) 9 + 10 + type ReactionKind string 11 + 12 + const ( 13 + Like ReactionKind = "๐Ÿ‘" 14 + Unlike = "๐Ÿ‘Ž" 15 + Laugh = "๐Ÿ˜†" 16 + Celebration = "๐ŸŽ‰" 17 + Confused = "๐Ÿซค" 18 + Heart = "โค๏ธ" 19 + Rocket = "๐Ÿš€" 20 + Eyes = "๐Ÿ‘€" 21 + ) 22 + 23 + func (rk ReactionKind) String() string { 24 + return string(rk) 25 + } 26 + 27 + var OrderedReactionKinds = []ReactionKind{ 28 + Like, 29 + Unlike, 30 + Laugh, 31 + Celebration, 32 + Confused, 33 + Heart, 34 + Rocket, 35 + Eyes, 36 + } 37 + 38 + func ParseReactionKind(raw string) (ReactionKind, bool) { 39 + k, ok := (map[string]ReactionKind{ 40 + "๐Ÿ‘": Like, 41 + "๐Ÿ‘Ž": Unlike, 42 + "๐Ÿ˜†": Laugh, 43 + "๐ŸŽ‰": Celebration, 44 + "๐Ÿซค": Confused, 45 + "โค๏ธ": Heart, 46 + "๐Ÿš€": Rocket, 47 + "๐Ÿ‘€": Eyes, 48 + })[raw] 49 + return k, ok 50 + } 51 + 52 + type Reaction struct { 53 + ReactedByDid string 54 + ThreadAt syntax.ATURI 55 + Created time.Time 56 + Rkey string 57 + Kind ReactionKind 58 + } 59 + 60 + func AddReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind, rkey string) error { 61 + query := `insert or ignore into reactions (reacted_by_did, thread_at, kind, rkey) values (?, ?, ?, ?)` 62 + _, err := e.Exec(query, reactedByDid, threadAt, kind, rkey) 63 + return err 64 + } 65 + 66 + // Get a reaction record 67 + func GetReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) (*Reaction, error) { 68 + query := ` 69 + select reacted_by_did, thread_at, created, rkey 70 + from reactions 71 + where reacted_by_did = ? and thread_at = ? and kind = ?` 72 + row := e.QueryRow(query, reactedByDid, threadAt, kind) 73 + 74 + var reaction Reaction 75 + var created string 76 + err := row.Scan(&reaction.ReactedByDid, &reaction.ThreadAt, &created, &reaction.Rkey) 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + createdAtTime, err := time.Parse(time.RFC3339, created) 82 + if err != nil { 83 + log.Println("unable to determine followed at time") 84 + reaction.Created = time.Now() 85 + } else { 86 + reaction.Created = createdAtTime 87 + } 88 + 89 + return &reaction, nil 90 + } 91 + 92 + // Remove a reaction 93 + func DeleteReaction(e Execer, reactedByDid string, threadAt syntax.ATURI, kind ReactionKind) error { 94 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and thread_at = ? and kind = ?`, reactedByDid, threadAt, kind) 95 + return err 96 + } 97 + 98 + // Remove a reaction 99 + func DeleteReactionByRkey(e Execer, reactedByDid string, rkey string) error { 100 + _, err := e.Exec(`delete from reactions where reacted_by_did = ? and rkey = ?`, reactedByDid, rkey) 101 + return err 102 + } 103 + 104 + func GetReactionCount(e Execer, threadAt syntax.ATURI, kind ReactionKind) (int, error) { 105 + count := 0 106 + err := e.QueryRow( 107 + `select count(reacted_by_did) from reactions where thread_at = ? and kind = ?`, threadAt, kind).Scan(&count) 108 + if err != nil { 109 + return 0, err 110 + } 111 + return count, nil 112 + } 113 + 114 + func GetReactionCountMap(e Execer, threadAt syntax.ATURI) (map[ReactionKind]int, error) { 115 + countMap := map[ReactionKind]int{} 116 + for _, kind := range OrderedReactionKinds { 117 + count, err := GetReactionCount(e, threadAt, kind) 118 + if err != nil { 119 + return map[ReactionKind]int{}, nil 120 + } 121 + countMap[kind] = count 122 + } 123 + return countMap, nil 124 + } 125 + 126 + func GetReactionStatus(e Execer, userDid string, threadAt syntax.ATURI, kind ReactionKind) bool { 127 + if _, err := GetReaction(e, userDid, threadAt, kind); err != nil { 128 + return false 129 + } else { 130 + return true 131 + } 132 + } 133 + 134 + func GetReactionStatusMap(e Execer, userDid string, threadAt syntax.ATURI) map[ReactionKind]bool { 135 + statusMap := map[ReactionKind]bool{} 136 + for _, kind := range OrderedReactionKinds { 137 + count := GetReactionStatus(e, userDid, threadAt, kind) 138 + statusMap[kind] = count 139 + } 140 + return statusMap 141 + }
+5 -4
appview/db/registration.go
··· 10 10 ) 11 11 12 12 type Registration struct { 13 + Id int64 13 14 Domain string 14 15 ByDid string 15 16 Created *time.Time ··· 36 37 var registrations []Registration 37 38 38 39 rows, err := e.Query(` 39 - select domain, did, created, registered from registrations 40 + select id, domain, did, created, registered from registrations 40 41 where did = ? 41 42 `, did) 42 43 if err != nil { ··· 47 48 var createdAt *string 48 49 var registeredAt *string 49 50 var registration Registration 50 - err = rows.Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 51 + err = rows.Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 51 52 52 53 if err != nil { 53 54 log.Println(err) ··· 75 76 var registration Registration 76 77 77 78 err := e.QueryRow(` 78 - select domain, did, created, registered from registrations 79 + select id, domain, did, created, registered from registrations 79 80 where domain = ? 80 - `, domain).Scan(&registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 + `, domain).Scan(&registration.Id, &registration.Domain, &registration.ByDid, &createdAt, &registeredAt) 81 82 82 83 if err != nil { 83 84 if err == sql.ErrNoRows {
+77 -45
appview/db/repos.go
··· 3 3 import ( 4 4 "database/sql" 5 5 "fmt" 6 + "log" 7 + "slices" 6 8 "strings" 7 9 "time" 8 10 ··· 71 73 return repos, nil 72 74 } 73 75 74 - func GetRepos(e Execer, filters ...filter) ([]Repo, error) { 75 - repoMap := make(map[syntax.ATURI]Repo) 76 + func GetRepos(e Execer, limit int, filters ...filter) ([]Repo, error) { 77 + repoMap := make(map[syntax.ATURI]*Repo) 76 78 77 79 var conditions []string 78 80 var args []any ··· 86 88 whereClause = " where " + strings.Join(conditions, " and ") 87 89 } 88 90 91 + limitClause := "" 92 + if limit != 0 { 93 + limitClause = fmt.Sprintf(" limit %d", limit) 94 + } 95 + 89 96 repoQuery := fmt.Sprintf( 90 97 `select 91 98 did, ··· 98 105 spindle 99 106 from 100 107 repos r 108 + %s 109 + order by created desc 101 110 %s`, 102 111 whereClause, 112 + limitClause, 103 113 ) 104 114 rows, err := e.Query(repoQuery, args...) 105 115 ··· 139 149 repo.Spindle = spindle.String 140 150 } 141 151 142 - repoMap[repo.RepoAt()] = repo 152 + repo.RepoStats = &RepoStats{} 153 + repoMap[repo.RepoAt()] = &repo 143 154 } 144 155 145 156 if err = rows.Err(); err != nil { ··· 148 159 149 160 inClause := strings.TrimSuffix(strings.Repeat("?, ", len(repoMap)), ", ") 150 161 args = make([]any, len(repoMap)) 162 + 163 + i := 0 151 164 for _, r := range repoMap { 152 - args = append(args, r.RepoAt()) 165 + args[i] = r.RepoAt() 166 + i++ 167 + } 168 + 169 + languageQuery := fmt.Sprintf( 170 + ` 171 + select 172 + repo_at, language 173 + from 174 + repo_languages r1 175 + where 176 + repo_at IN (%s) 177 + and is_default_ref = 1 178 + and id = ( 179 + select id 180 + from repo_languages r2 181 + where r2.repo_at = r1.repo_at 182 + and r2.is_default_ref = 1 183 + order by bytes desc 184 + limit 1 185 + ); 186 + `, 187 + inClause, 188 + ) 189 + rows, err = e.Query(languageQuery, args...) 190 + if err != nil { 191 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 192 + } 193 + for rows.Next() { 194 + var repoat, lang string 195 + if err := rows.Scan(&repoat, &lang); err != nil { 196 + log.Println("err", "err", err) 197 + continue 198 + } 199 + if r, ok := repoMap[syntax.ATURI(repoat)]; ok { 200 + r.RepoStats.Language = lang 201 + } 202 + } 203 + if err = rows.Err(); err != nil { 204 + return nil, fmt.Errorf("failed to execute lang query: %w ", err) 153 205 } 154 206 155 207 starCountQuery := fmt.Sprintf( ··· 168 220 var repoat string 169 221 var count int 170 222 if err := rows.Scan(&repoat, &count); err != nil { 223 + log.Println("err", "err", err) 171 224 continue 172 225 } 173 226 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 196 249 var repoat string 197 250 var open, closed int 198 251 if err := rows.Scan(&repoat, &open, &closed); err != nil { 252 + log.Println("err", "err", err) 199 253 continue 200 254 } 201 255 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 236 290 var repoat string 237 291 var open, merged, closed, deleted int 238 292 if err := rows.Scan(&repoat, &open, &merged, &closed, &deleted); err != nil { 293 + log.Println("err", "err", err) 239 294 continue 240 295 } 241 296 if r, ok := repoMap[syntax.ATURI(repoat)]; ok { ··· 251 306 252 307 var repos []Repo 253 308 for _, r := range repoMap { 254 - repos = append(repos, r) 309 + repos = append(repos, *r) 255 310 } 311 + 312 + slices.SortFunc(repos, func(a, b Repo) int { 313 + if a.Created.After(b.Created) { 314 + return 1 315 + } 316 + return -1 317 + }) 256 318 257 319 return repos, nil 258 320 } ··· 509 571 } 510 572 511 573 func CollaboratingIn(e Execer, collaborator string) ([]Repo, error) { 512 - var repos []Repo 513 - 514 - rows, err := e.Query( 515 - `select 516 - r.did, r.name, r.knot, r.rkey, r.description, r.created, count(s.id) as star_count 517 - from 518 - repos r 519 - join 520 - collaborators c on r.id = c.repo 521 - left join 522 - stars s on r.at_uri = s.repo_at 523 - where 524 - c.did = ? 525 - group by 526 - r.id;`, collaborator) 574 + rows, err := e.Query(`select repo from collaborators where did = ?`, collaborator) 527 575 if err != nil { 528 576 return nil, err 529 577 } 530 578 defer rows.Close() 531 579 580 + var repoIds []int 532 581 for rows.Next() { 533 - var repo Repo 534 - var repoStats RepoStats 535 - var createdAt string 536 - var nullableDescription sql.NullString 537 - 538 - err := rows.Scan(&repo.Did, &repo.Name, &repo.Knot, &repo.Rkey, &nullableDescription, &createdAt, &repoStats.StarCount) 582 + var id int 583 + err := rows.Scan(&id) 539 584 if err != nil { 540 585 return nil, err 541 586 } 542 - 543 - if nullableDescription.Valid { 544 - repo.Description = nullableDescription.String 545 - } else { 546 - repo.Description = "" 547 - } 548 - 549 - createdAtTime, err := time.Parse(time.RFC3339, createdAt) 550 - if err != nil { 551 - repo.Created = time.Now() 552 - } else { 553 - repo.Created = createdAtTime 554 - } 555 - 556 - repo.RepoStats = &repoStats 557 - 558 - repos = append(repos, repo) 587 + repoIds = append(repoIds, id) 559 588 } 560 - 561 589 if err := rows.Err(); err != nil { 562 590 return nil, err 563 591 } 592 + if repoIds == nil { 593 + return nil, nil 594 + } 564 595 565 - return repos, nil 596 + return GetRepos(e, 0, FilterIn("id", repoIds)) 566 597 } 567 598 568 599 type RepoStats struct { 600 + Language string 569 601 StarCount int 570 602 IssueCount IssueCount 571 603 PullCount PullCount
+85
appview/db/star.go
··· 1 1 package db 2 2 3 3 import ( 4 + "fmt" 4 5 "log" 6 + "strings" 5 7 "time" 6 8 7 9 "github.com/bluesky-social/indigo/atproto/syntax" ··· 91 93 } else { 92 94 return true 93 95 } 96 + } 97 + 98 + func GetStars(e Execer, limit int, filters ...filter) ([]Star, error) { 99 + var conditions []string 100 + var args []any 101 + for _, filter := range filters { 102 + conditions = append(conditions, filter.Condition()) 103 + args = append(args, filter.Arg()...) 104 + } 105 + 106 + whereClause := "" 107 + if conditions != nil { 108 + whereClause = " where " + strings.Join(conditions, " and ") 109 + } 110 + 111 + limitClause := "" 112 + if limit != 0 { 113 + limitClause = fmt.Sprintf(" limit %d", limit) 114 + } 115 + 116 + repoQuery := fmt.Sprintf( 117 + `select starred_by_did, repo_at, created, rkey 118 + from stars 119 + %s 120 + order by created desc 121 + %s`, 122 + whereClause, 123 + limitClause, 124 + ) 125 + rows, err := e.Query(repoQuery, args...) 126 + if err != nil { 127 + return nil, err 128 + } 129 + 130 + starMap := make(map[string][]Star) 131 + for rows.Next() { 132 + var star Star 133 + var created string 134 + err := rows.Scan(&star.StarredByDid, &star.RepoAt, &created, &star.Rkey) 135 + if err != nil { 136 + return nil, err 137 + } 138 + 139 + star.Created = time.Now() 140 + if t, err := time.Parse(time.RFC3339, created); err == nil { 141 + star.Created = t 142 + } 143 + 144 + repoAt := string(star.RepoAt) 145 + starMap[repoAt] = append(starMap[repoAt], star) 146 + } 147 + 148 + // populate *Repo in each star 149 + args = make([]any, len(starMap)) 150 + i := 0 151 + for r := range starMap { 152 + args[i] = r 153 + i++ 154 + } 155 + 156 + if len(args) == 0 { 157 + return nil, nil 158 + } 159 + 160 + repos, err := GetRepos(e, 0, FilterIn("at_uri", args)) 161 + if err != nil { 162 + return nil, err 163 + } 164 + 165 + for _, r := range repos { 166 + if stars, ok := starMap[string(r.RepoAt())]; ok { 167 + for i := range stars { 168 + stars[i].Repo = &r 169 + } 170 + } 171 + } 172 + 173 + var stars []Star 174 + for _, s := range starMap { 175 + stars = append(stars, s...) 176 + } 177 + 178 + return stars, nil 94 179 } 95 180 96 181 func GetAllStars(e Execer, limit int) ([]Star, error) {
+136 -27
appview/db/timeline.go
··· 14 14 15 15 // optional: populate only if Repo is a fork 16 16 Source *Repo 17 + 18 + // optional: populate only if event is Follow 19 + *Profile 20 + *FollowStats 17 21 } 22 + 23 + type FollowStats struct { 24 + Followers int 25 + Following int 26 + } 27 + 28 + const Limit = 50 18 29 19 30 // TODO: this gathers heterogenous events from different sources and aggregates 20 31 // them in code; if we did this entirely in sql, we could order and limit and paginate easily 21 32 func MakeTimeline(e Execer) ([]TimelineEvent, error) { 22 33 var events []TimelineEvent 23 - limit := 50 24 34 25 - repos, err := GetAllRepos(e, limit) 35 + repos, err := getTimelineRepos(e) 26 36 if err != nil { 27 37 return nil, err 28 38 } 29 39 30 - follows, err := GetAllFollows(e, limit) 40 + stars, err := getTimelineStars(e) 31 41 if err != nil { 32 42 return nil, err 33 43 } 34 44 35 - stars, err := GetAllStars(e, limit) 45 + follows, err := getTimelineFollows(e) 36 46 if err != nil { 37 47 return nil, err 38 48 } 39 49 40 - for _, repo := range repos { 41 - var sourceRepo *Repo 42 - if repo.Source != "" { 43 - sourceRepo, err = GetRepoByAtUri(e, repo.Source) 44 - if err != nil { 45 - return nil, err 50 + events = append(events, repos...) 51 + events = append(events, stars...) 52 + events = append(events, follows...) 53 + 54 + sort.Slice(events, func(i, j int) bool { 55 + return events[i].EventAt.After(events[j].EventAt) 56 + }) 57 + 58 + // Limit the slice to 100 events 59 + if len(events) > Limit { 60 + events = events[:Limit] 61 + } 62 + 63 + return events, nil 64 + } 65 + 66 + func getTimelineRepos(e Execer) ([]TimelineEvent, error) { 67 + repos, err := GetRepos(e, Limit) 68 + if err != nil { 69 + return nil, err 70 + } 71 + 72 + // fetch all source repos 73 + var args []string 74 + for _, r := range repos { 75 + if r.Source != "" { 76 + args = append(args, r.Source) 77 + } 78 + } 79 + 80 + var origRepos []Repo 81 + if args != nil { 82 + origRepos, err = GetRepos(e, 0, FilterIn("at_uri", args)) 83 + } 84 + if err != nil { 85 + return nil, err 86 + } 87 + 88 + uriToRepo := make(map[string]Repo) 89 + for _, r := range origRepos { 90 + uriToRepo[r.RepoAt().String()] = r 91 + } 92 + 93 + var events []TimelineEvent 94 + for _, r := range repos { 95 + var source *Repo 96 + if r.Source != "" { 97 + if origRepo, ok := uriToRepo[r.Source]; ok { 98 + source = &origRepo 46 99 } 47 100 } 48 101 49 102 events = append(events, TimelineEvent{ 50 - Repo: &repo, 51 - EventAt: repo.Created, 52 - Source: sourceRepo, 103 + Repo: &r, 104 + EventAt: r.Created, 105 + Source: source, 53 106 }) 54 107 } 55 108 56 - for _, follow := range follows { 109 + return events, nil 110 + } 111 + 112 + func getTimelineStars(e Execer) ([]TimelineEvent, error) { 113 + stars, err := GetStars(e, Limit) 114 + if err != nil { 115 + return nil, err 116 + } 117 + 118 + // filter star records without a repo 119 + n := 0 120 + for _, s := range stars { 121 + if s.Repo != nil { 122 + stars[n] = s 123 + n++ 124 + } 125 + } 126 + stars = stars[:n] 127 + 128 + var events []TimelineEvent 129 + for _, s := range stars { 57 130 events = append(events, TimelineEvent{ 58 - Follow: &follow, 59 - EventAt: follow.FollowedAt, 131 + Star: &s, 132 + EventAt: s.Created, 60 133 }) 61 134 } 62 135 63 - for _, star := range stars { 64 - events = append(events, TimelineEvent{ 65 - Star: &star, 66 - EventAt: star.Created, 67 - }) 136 + return events, nil 137 + } 138 + 139 + func getTimelineFollows(e Execer) ([]TimelineEvent, error) { 140 + follows, err := GetAllFollows(e, Limit) 141 + if err != nil { 142 + return nil, err 143 + } 144 + 145 + var subjects []string 146 + for _, f := range follows { 147 + subjects = append(subjects, f.SubjectDid) 148 + } 149 + 150 + if subjects == nil { 151 + return nil, nil 152 + } 153 + 154 + profileMap := make(map[string]Profile) 155 + profiles, err := GetProfiles(e, FilterIn("did", subjects)) 156 + if err != nil { 157 + return nil, err 158 + } 159 + for _, p := range profiles { 160 + profileMap[p.Did] = p 68 161 } 69 162 70 - sort.Slice(events, func(i, j int) bool { 71 - return events[i].EventAt.After(events[j].EventAt) 72 - }) 163 + followStatMap := make(map[string]FollowStats) 164 + for _, s := range subjects { 165 + followers, following, err := GetFollowerFollowing(e, s) 166 + if err != nil { 167 + return nil, err 168 + } 169 + followStatMap[s] = FollowStats{ 170 + Followers: followers, 171 + Following: following, 172 + } 173 + } 73 174 74 - // Limit the slice to 100 events 75 - if len(events) > limit { 76 - events = events[:limit] 175 + var events []TimelineEvent 176 + for _, f := range follows { 177 + profile, _ := profileMap[f.SubjectDid] 178 + followStatMap, _ := followStatMap[f.SubjectDid] 179 + 180 + events = append(events, TimelineEvent{ 181 + Follow: &f, 182 + Profile: &profile, 183 + FollowStats: &followStatMap, 184 + EventAt: f.FollowedAt, 185 + }) 77 186 } 78 187 79 188 return events, nil
+6 -3
appview/ingester.go
··· 492 492 if err != nil || len(spindles) != 1 { 493 493 return fmt.Errorf("failed to get spindles: %w, len(spindles) = %d", err, len(spindles)) 494 494 } 495 + spindle := spindles[0] 495 496 496 497 tx, err := ddb.Begin() 497 498 if err != nil { ··· 511 512 return err 512 513 } 513 514 514 - err = i.Enforcer.RemoveSpindle(instance) 515 - if err != nil { 516 - return err 515 + if spindle.Verified != nil { 516 + err = i.Enforcer.RemoveSpindle(instance) 517 + if err != nil { 518 + return err 519 + } 517 520 } 518 521 519 522 err = tx.Commit()
+16
appview/issues/issues.go
··· 11 11 12 12 comatproto "github.com/bluesky-social/indigo/api/atproto" 13 13 "github.com/bluesky-social/indigo/atproto/data" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 14 15 lexutil "github.com/bluesky-social/indigo/lex/util" 15 16 "github.com/go-chi/chi/v5" 16 17 "github.com/posthog/posthog-go" ··· 79 80 return 80 81 } 81 82 83 + reactionCountMap, err := db.GetReactionCountMap(rp.db, syntax.ATURI(issue.IssueAt)) 84 + if err != nil { 85 + log.Println("failed to get issue reactions") 86 + rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.") 87 + } 88 + 89 + userReactions := map[db.ReactionKind]bool{} 90 + if user != nil { 91 + userReactions = db.GetReactionStatusMap(rp.db, user.Did, syntax.ATURI(issue.IssueAt)) 92 + } 93 + 82 94 issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid) 83 95 if err != nil { 84 96 log.Println("failed to resolve issue owner", err) ··· 106 118 107 119 IssueOwnerHandle: issueOwnerIdent.Handle.String(), 108 120 DidHandleMap: didHandleMap, 121 + 122 + OrderedReactionKinds: db.OrderedReactionKinds, 123 + Reactions: reactionCountMap, 124 + UserReacted: userReactions, 109 125 }) 110 126 111 127 }
+495
appview/knots/knots.go
··· 1 + package knots 2 + 3 + import ( 4 + "context" 5 + "crypto/hmac" 6 + "crypto/sha256" 7 + "encoding/hex" 8 + "fmt" 9 + "log/slog" 10 + "net/http" 11 + "strings" 12 + "time" 13 + 14 + "github.com/go-chi/chi/v5" 15 + "tangled.sh/tangled.sh/core/api/tangled" 16 + "tangled.sh/tangled.sh/core/appview" 17 + "tangled.sh/tangled.sh/core/appview/config" 18 + "tangled.sh/tangled.sh/core/appview/db" 19 + "tangled.sh/tangled.sh/core/appview/idresolver" 20 + "tangled.sh/tangled.sh/core/appview/middleware" 21 + "tangled.sh/tangled.sh/core/appview/oauth" 22 + "tangled.sh/tangled.sh/core/appview/pages" 23 + "tangled.sh/tangled.sh/core/eventconsumer" 24 + "tangled.sh/tangled.sh/core/knotclient" 25 + "tangled.sh/tangled.sh/core/rbac" 26 + 27 + comatproto "github.com/bluesky-social/indigo/api/atproto" 28 + lexutil "github.com/bluesky-social/indigo/lex/util" 29 + ) 30 + 31 + type Knots struct { 32 + Db *db.DB 33 + OAuth *oauth.OAuth 34 + Pages *pages.Pages 35 + Config *config.Config 36 + Enforcer *rbac.Enforcer 37 + IdResolver *idresolver.Resolver 38 + Logger *slog.Logger 39 + Knotstream *eventconsumer.Consumer 40 + } 41 + 42 + func (k *Knots) Router(mw *middleware.Middleware) http.Handler { 43 + r := chi.NewRouter() 44 + 45 + r.Use(middleware.AuthMiddleware(k.OAuth)) 46 + 47 + r.Get("/", k.index) 48 + r.Post("/key", k.generateKey) 49 + 50 + r.Route("/{domain}", func(r chi.Router) { 51 + r.Post("/init", k.init) 52 + r.Get("/", k.dashboard) 53 + r.Route("/member", func(r chi.Router) { 54 + r.Use(mw.KnotOwner()) 55 + r.Get("/", k.members) 56 + r.Put("/", k.addMember) 57 + r.Delete("/", k.removeMember) 58 + }) 59 + }) 60 + 61 + return r 62 + } 63 + 64 + // get knots registered by this user 65 + func (k *Knots) index(w http.ResponseWriter, r *http.Request) { 66 + l := k.Logger.With("handler", "index") 67 + 68 + user := k.OAuth.GetUser(r) 69 + registrations, err := db.RegistrationsByDid(k.Db, user.Did) 70 + if err != nil { 71 + l.Error("failed to get registrations by did", "err", err) 72 + } 73 + 74 + k.Pages.Knots(w, pages.KnotsParams{ 75 + LoggedInUser: user, 76 + Registrations: registrations, 77 + }) 78 + } 79 + 80 + // requires auth 81 + func (k *Knots) generateKey(w http.ResponseWriter, r *http.Request) { 82 + l := k.Logger.With("handler", "generateKey") 83 + 84 + user := k.OAuth.GetUser(r) 85 + did := user.Did 86 + l = l.With("did", did) 87 + 88 + // check if domain is valid url, and strip extra bits down to just host 89 + domain := r.FormValue("domain") 90 + if domain == "" { 91 + l.Error("empty domain") 92 + http.Error(w, "Invalid form", http.StatusBadRequest) 93 + return 94 + } 95 + l = l.With("domain", domain) 96 + 97 + noticeId := "registration-error" 98 + fail := func() { 99 + k.Pages.Notice(w, noticeId, "Failed to generate registration key.") 100 + } 101 + 102 + key, err := db.GenerateRegistrationKey(k.Db, domain, did) 103 + if err != nil { 104 + l.Error("failed to generate registration key", "err", err) 105 + fail() 106 + return 107 + } 108 + 109 + allRegs, err := db.RegistrationsByDid(k.Db, did) 110 + if err != nil { 111 + l.Error("failed to generate registration key", "err", err) 112 + fail() 113 + return 114 + } 115 + 116 + k.Pages.KnotListingFull(w, pages.KnotListingFullParams{ 117 + Registrations: allRegs, 118 + }) 119 + k.Pages.KnotSecret(w, pages.KnotSecretParams{ 120 + Secret: key, 121 + }) 122 + } 123 + 124 + // create a signed request and check if a node responds to that 125 + func (k *Knots) init(w http.ResponseWriter, r *http.Request) { 126 + l := k.Logger.With("handler", "init") 127 + user := k.OAuth.GetUser(r) 128 + 129 + noticeId := "operation-error" 130 + defaultErr := "Failed to initialize knot. Try again later." 131 + fail := func() { 132 + k.Pages.Notice(w, noticeId, defaultErr) 133 + } 134 + 135 + domain := chi.URLParam(r, "domain") 136 + if domain == "" { 137 + http.Error(w, "malformed url", http.StatusBadRequest) 138 + return 139 + } 140 + l = l.With("domain", domain) 141 + 142 + l.Info("checking domain") 143 + 144 + registration, err := db.RegistrationByDomain(k.Db, domain) 145 + if err != nil { 146 + l.Error("failed to get registration for domain", "err", err) 147 + fail() 148 + return 149 + } 150 + if registration.ByDid != user.Did { 151 + l.Error("unauthorized", "wantedDid", registration.ByDid, "gotDid", user.Did) 152 + w.WriteHeader(http.StatusUnauthorized) 153 + return 154 + } 155 + 156 + secret, err := db.GetRegistrationKey(k.Db, domain) 157 + if err != nil { 158 + l.Error("failed to get registration key for domain", "err", err) 159 + fail() 160 + return 161 + } 162 + 163 + client, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 164 + if err != nil { 165 + l.Error("failed to create knotclient", "err", err) 166 + fail() 167 + return 168 + } 169 + 170 + resp, err := client.Init(user.Did) 171 + if err != nil { 172 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Failed to make request: %s", err.Error())) 173 + l.Error("failed to make init request", "err", err) 174 + return 175 + } 176 + 177 + if resp.StatusCode == http.StatusConflict { 178 + k.Pages.Notice(w, noticeId, "This knot is already registered") 179 + l.Error("knot already registered", "statuscode", resp.StatusCode) 180 + return 181 + } 182 + 183 + if resp.StatusCode != http.StatusNoContent { 184 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Received status %d from knot, expected %d", resp.StatusCode, http.StatusNoContent)) 185 + l.Error("incorrect statuscode returned", "statuscode", resp.StatusCode, "expected", http.StatusNoContent) 186 + return 187 + } 188 + 189 + // verify response mac 190 + signature := resp.Header.Get("X-Signature") 191 + signatureBytes, err := hex.DecodeString(signature) 192 + if err != nil { 193 + return 194 + } 195 + 196 + expectedMac := hmac.New(sha256.New, []byte(secret)) 197 + expectedMac.Write([]byte("ok")) 198 + 199 + if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 200 + k.Pages.Notice(w, noticeId, "Response signature mismatch, consider regenerating the secret and retrying.") 201 + l.Error("signature mismatch", "bytes", signatureBytes) 202 + return 203 + } 204 + 205 + tx, err := k.Db.BeginTx(r.Context(), nil) 206 + if err != nil { 207 + l.Error("failed to start tx", "err", err) 208 + fail() 209 + return 210 + } 211 + defer func() { 212 + tx.Rollback() 213 + err = k.Enforcer.E.LoadPolicy() 214 + if err != nil { 215 + l.Error("rollback failed", "err", err) 216 + } 217 + }() 218 + 219 + // mark as registered 220 + err = db.Register(tx, domain) 221 + if err != nil { 222 + l.Error("failed to register domain", "err", err) 223 + fail() 224 + return 225 + } 226 + 227 + // set permissions for this did as owner 228 + reg, err := db.RegistrationByDomain(tx, domain) 229 + if err != nil { 230 + l.Error("failed get registration by domain", "err", err) 231 + fail() 232 + return 233 + } 234 + 235 + // add basic acls for this domain 236 + err = k.Enforcer.AddKnot(domain) 237 + if err != nil { 238 + l.Error("failed to add knot to enforcer", "err", err) 239 + fail() 240 + return 241 + } 242 + 243 + // add this did as owner of this domain 244 + err = k.Enforcer.AddKnotOwner(domain, reg.ByDid) 245 + if err != nil { 246 + l.Error("failed to add knot owner to enforcer", "err", err) 247 + fail() 248 + return 249 + } 250 + 251 + err = tx.Commit() 252 + if err != nil { 253 + l.Error("failed to commit changes", "err", err) 254 + fail() 255 + return 256 + } 257 + 258 + err = k.Enforcer.E.SavePolicy() 259 + if err != nil { 260 + l.Error("failed to update ACLs", "err", err) 261 + fail() 262 + return 263 + } 264 + 265 + // add this knot to knotstream 266 + go k.Knotstream.AddSource( 267 + context.Background(), 268 + eventconsumer.NewKnotSource(domain), 269 + ) 270 + 271 + k.Pages.KnotListing(w, pages.KnotListingParams{ 272 + Registration: *reg, 273 + }) 274 + } 275 + 276 + func (k *Knots) dashboard(w http.ResponseWriter, r *http.Request) { 277 + l := k.Logger.With("handler", "dashboard") 278 + fail := func() { 279 + w.WriteHeader(http.StatusInternalServerError) 280 + } 281 + 282 + domain := chi.URLParam(r, "domain") 283 + if domain == "" { 284 + http.Error(w, "malformed url", http.StatusBadRequest) 285 + return 286 + } 287 + l = l.With("domain", domain) 288 + 289 + user := k.OAuth.GetUser(r) 290 + l = l.With("did", user.Did) 291 + 292 + // dashboard is only available to owners 293 + ok, err := k.Enforcer.IsKnotOwner(user.Did, domain) 294 + if err != nil { 295 + l.Error("failed to query enforcer", "err", err) 296 + fail() 297 + } 298 + if !ok { 299 + http.Error(w, "only owners can view dashboards", http.StatusUnauthorized) 300 + return 301 + } 302 + 303 + reg, err := db.RegistrationByDomain(k.Db, domain) 304 + if err != nil { 305 + l.Error("failed to get registration by domain", "err", err) 306 + fail() 307 + return 308 + } 309 + 310 + var members []string 311 + if reg.Registered != nil { 312 + members, err = k.Enforcer.GetUserByRole("server:member", domain) 313 + if err != nil { 314 + l.Error("failed to get members list", "err", err) 315 + fail() 316 + return 317 + } 318 + } 319 + 320 + repos, err := db.GetRepos( 321 + k.Db, 322 + 0, 323 + db.FilterEq("knot", domain), 324 + db.FilterIn("did", members), 325 + ) 326 + if err != nil { 327 + l.Error("failed to get repos list", "err", err) 328 + fail() 329 + return 330 + } 331 + // convert to map 332 + repoByMember := make(map[string][]db.Repo) 333 + for _, r := range repos { 334 + repoByMember[r.Did] = append(repoByMember[r.Did], r) 335 + } 336 + 337 + var didsToResolve []string 338 + for _, m := range members { 339 + didsToResolve = append(didsToResolve, m) 340 + } 341 + didsToResolve = append(didsToResolve, reg.ByDid) 342 + resolvedIds := k.IdResolver.ResolveIdents(r.Context(), didsToResolve) 343 + didHandleMap := make(map[string]string) 344 + for _, identity := range resolvedIds { 345 + if !identity.Handle.IsInvalidHandle() { 346 + didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 347 + } else { 348 + didHandleMap[identity.DID.String()] = identity.DID.String() 349 + } 350 + } 351 + 352 + k.Pages.Knot(w, pages.KnotParams{ 353 + LoggedInUser: user, 354 + DidHandleMap: didHandleMap, 355 + Registration: reg, 356 + Members: members, 357 + Repos: repoByMember, 358 + IsOwner: true, 359 + }) 360 + } 361 + 362 + // list members of domain, requires auth and requires owner status 363 + func (k *Knots) members(w http.ResponseWriter, r *http.Request) { 364 + l := k.Logger.With("handler", "members") 365 + 366 + domain := chi.URLParam(r, "domain") 367 + if domain == "" { 368 + http.Error(w, "malformed url", http.StatusBadRequest) 369 + return 370 + } 371 + l = l.With("domain", domain) 372 + 373 + // list all members for this domain 374 + memberDids, err := k.Enforcer.GetUserByRole("server:member", domain) 375 + if err != nil { 376 + w.Write([]byte("failed to fetch member list")) 377 + return 378 + } 379 + 380 + w.Write([]byte(strings.Join(memberDids, "\n"))) 381 + return 382 + } 383 + 384 + // add member to domain, requires auth and requires invite access 385 + func (k *Knots) addMember(w http.ResponseWriter, r *http.Request) { 386 + l := k.Logger.With("handler", "members") 387 + 388 + domain := chi.URLParam(r, "domain") 389 + if domain == "" { 390 + http.Error(w, "malformed url", http.StatusBadRequest) 391 + return 392 + } 393 + l = l.With("domain", domain) 394 + 395 + reg, err := db.RegistrationByDomain(k.Db, domain) 396 + if err != nil { 397 + l.Error("failed to get registration by domain", "err", err) 398 + http.Error(w, "malformed url", http.StatusBadRequest) 399 + return 400 + } 401 + 402 + noticeId := fmt.Sprintf("add-member-error-%d", reg.Id) 403 + l = l.With("notice-id", noticeId) 404 + defaultErr := "Failed to add member. Try again later." 405 + fail := func() { 406 + k.Pages.Notice(w, noticeId, defaultErr) 407 + } 408 + 409 + subjectIdentifier := r.FormValue("subject") 410 + if subjectIdentifier == "" { 411 + http.Error(w, "malformed form", http.StatusBadRequest) 412 + return 413 + } 414 + l = l.With("subjectIdentifier", subjectIdentifier) 415 + 416 + subjectIdentity, err := k.IdResolver.ResolveIdent(r.Context(), subjectIdentifier) 417 + if err != nil { 418 + l.Error("failed to resolve identity", "err", err) 419 + k.Pages.Notice(w, noticeId, "Failed to add member, identity resolution failed.") 420 + return 421 + } 422 + l = l.With("subjectDid", subjectIdentity.DID) 423 + 424 + l.Info("adding member to knot") 425 + 426 + // announce this relation into the firehose, store into owners' pds 427 + client, err := k.OAuth.AuthorizedClient(r) 428 + if err != nil { 429 + l.Error("failed to create client", "err", err) 430 + fail() 431 + return 432 + } 433 + 434 + currentUser := k.OAuth.GetUser(r) 435 + createdAt := time.Now().Format(time.RFC3339) 436 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 437 + Collection: tangled.KnotMemberNSID, 438 + Repo: currentUser.Did, 439 + Rkey: appview.TID(), 440 + Record: &lexutil.LexiconTypeDecoder{ 441 + Val: &tangled.KnotMember{ 442 + Subject: subjectIdentity.DID.String(), 443 + Domain: domain, 444 + CreatedAt: createdAt, 445 + }}, 446 + }) 447 + // invalid record 448 + if err != nil { 449 + l.Error("failed to write to PDS", "err", err) 450 + fail() 451 + return 452 + } 453 + l = l.With("at-uri", resp.Uri) 454 + l.Info("wrote record to PDS") 455 + 456 + secret, err := db.GetRegistrationKey(k.Db, domain) 457 + if err != nil { 458 + l.Error("failed to get registration key", "err", err) 459 + fail() 460 + return 461 + } 462 + 463 + ksClient, err := knotclient.NewSignedClient(domain, secret, k.Config.Core.Dev) 464 + if err != nil { 465 + l.Error("failed to create client", "err", err) 466 + fail() 467 + return 468 + } 469 + 470 + ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 471 + if err != nil { 472 + l.Error("failed to reach knotserver", "err", err) 473 + k.Pages.Notice(w, noticeId, "Failed to reach to knotserver.") 474 + return 475 + } 476 + 477 + if ksResp.StatusCode != http.StatusNoContent { 478 + l.Error("status mismatch", "got", ksResp.StatusCode, "expected", http.StatusNoContent) 479 + k.Pages.Notice(w, noticeId, fmt.Sprintf("Unexpected status code from knotserver %d, expected %d", ksResp.StatusCode, http.StatusNoContent)) 480 + return 481 + } 482 + 483 + err = k.Enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 484 + if err != nil { 485 + l.Error("failed to add member to enforcer", "err", err) 486 + fail() 487 + return 488 + } 489 + 490 + // success 491 + k.Pages.HxRedirect(w, fmt.Sprintf("/knots/%s", domain)) 492 + } 493 + 494 + func (k *Knots) removeMember(w http.ResponseWriter, r *http.Request) { 495 + }
+66 -30
appview/pages/funcmap.go
··· 17 17 "time" 18 18 19 19 "github.com/dustin/go-humanize" 20 + "github.com/go-enry/go-enry/v2" 20 21 "github.com/microcosm-cc/bluemonday" 21 22 "tangled.sh/tangled.sh/core/appview/filetree" 22 23 "tangled.sh/tangled.sh/core/appview/pages/markup" ··· 105 106 s = append(s, values...) 106 107 return s 107 108 }, 108 - "timeFmt": humanize.Time, 109 - "longTimeFmt": func(t time.Time) string { 110 - return t.Format("2006-01-02 * 3:04 PM") 111 - }, 112 - "commaFmt": humanize.Comma, 113 - "shortTimeFmt": func(t time.Time) string { 109 + "commaFmt": humanize.Comma, 110 + "relTimeFmt": humanize.Time, 111 + "shortRelTimeFmt": func(t time.Time) string { 114 112 return humanize.CustomRelTime(t, time.Now(), "", "", []humanize.RelTimeMagnitude{ 115 113 {time.Second, "now", time.Second}, 116 114 {2 * time.Second, "1s %s", 1}, ··· 129 127 {math.MaxInt64, "a long while %s", 1}, 130 128 }) 131 129 }, 132 - "durationFmt": func(duration time.Duration) string { 130 + "longTimeFmt": func(t time.Time) string { 131 + return t.Format("Jan 2, 2006, 3:04 PM MST") 132 + }, 133 + "iso8601DateTimeFmt": func(t time.Time) string { 134 + return t.Format("2006-01-02T15:04:05-07:00") 135 + }, 136 + "iso8601DurationFmt": func(duration time.Duration) string { 133 137 days := int64(duration.Hours() / 24) 134 138 hours := int64(math.Mod(duration.Hours(), 24)) 135 139 minutes := int64(math.Mod(duration.Minutes(), 60)) 136 140 seconds := int64(math.Mod(duration.Seconds(), 60)) 137 - 138 - chunks := []struct { 139 - name string 140 - amount int64 141 - }{ 142 - {"d", days}, 143 - {"hr", hours}, 144 - {"min", minutes}, 145 - {"s", seconds}, 146 - } 147 - 148 - parts := []string{} 149 - 150 - for _, chunk := range chunks { 151 - if chunk.amount != 0 { 152 - parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 153 - } 154 - } 155 - 156 - return strings.Join(parts, " ") 141 + return fmt.Sprintf("P%dD%dH%dM%dS", days, hours, minutes, seconds) 142 + }, 143 + "durationFmt": func(duration time.Duration) string { 144 + return durationFmt(duration, [4]string{"d", "hr", "min", "s"}) 145 + }, 146 + "longDurationFmt": func(duration time.Duration) string { 147 + return durationFmt(duration, [4]string{"days", "hours", "minutes", "seconds"}) 157 148 }, 158 149 "byteFmt": humanize.Bytes, 159 150 "length": func(slice any) int { ··· 250 241 return u 251 242 }, 252 243 253 - "tinyAvatar": p.tinyAvatar, 244 + "tinyAvatar": func(handle string) string { 245 + return p.avatarUri(handle, "tiny") 246 + }, 247 + "fullAvatar": func(handle string) string { 248 + return p.avatarUri(handle, "") 249 + }, 250 + "langColor": enry.GetColor, 251 + "layoutSide": func() string { 252 + return "col-span-1 md:col-span-2 lg:col-span-3" 253 + }, 254 + "layoutCenter": func() string { 255 + return "col-span-1 md:col-span-8 lg:col-span-6" 256 + }, 254 257 } 255 258 } 256 259 257 - func (p *Pages) tinyAvatar(handle string) string { 260 + func (p *Pages) avatarUri(handle, size string) string { 258 261 handle = strings.TrimPrefix(handle, "@") 262 + 259 263 secret := p.avatar.SharedSecret 260 264 h := hmac.New(sha256.New, []byte(secret)) 261 265 h.Write([]byte(handle)) 262 266 signature := hex.EncodeToString(h.Sum(nil)) 263 - return fmt.Sprintf("%s/%s/%s?size=tiny", p.avatar.Host, signature, handle) 267 + 268 + sizeArg := "" 269 + if size != "" { 270 + sizeArg = fmt.Sprintf("size=%s", size) 271 + } 272 + return fmt.Sprintf("%s/%s/%s?%s", p.avatar.Host, signature, handle, sizeArg) 264 273 } 265 274 266 275 func icon(name string, classes []string) (template.HTML, error) { ··· 288 297 modifiedSVG := svgStr[:svgTagEnd] + classTag + svgStr[svgTagEnd:] 289 298 return template.HTML(modifiedSVG), nil 290 299 } 300 + 301 + func durationFmt(duration time.Duration, names [4]string) string { 302 + days := int64(duration.Hours() / 24) 303 + hours := int64(math.Mod(duration.Hours(), 24)) 304 + minutes := int64(math.Mod(duration.Minutes(), 60)) 305 + seconds := int64(math.Mod(duration.Seconds(), 60)) 306 + 307 + chunks := []struct { 308 + name string 309 + amount int64 310 + }{ 311 + {names[0], days}, 312 + {names[1], hours}, 313 + {names[2], minutes}, 314 + {names[3], seconds}, 315 + } 316 + 317 + parts := []string{} 318 + 319 + for _, chunk := range chunks { 320 + if chunk.amount != 0 { 321 + parts = append(parts, fmt.Sprintf("%d%s", chunk.amount, chunk.name)) 322 + } 323 + } 324 + 325 + return strings.Join(parts, " ") 326 + }
+77 -17
appview/pages/pages.go
··· 14 14 "os" 15 15 "path/filepath" 16 16 "strings" 17 + "sync" 17 18 18 19 "tangled.sh/tangled.sh/core/appview/commitverify" 19 20 "tangled.sh/tangled.sh/core/appview/config" ··· 39 40 var Files embed.FS 40 41 41 42 type Pages struct { 42 - t map[string]*template.Template 43 + mu sync.RWMutex 44 + t map[string]*template.Template 45 + 43 46 avatar config.AvatarConfig 44 47 dev bool 45 48 embedFS embed.FS ··· 56 59 } 57 60 58 61 p := &Pages{ 62 + mu: sync.RWMutex{}, 59 63 t: make(map[string]*template.Template), 60 64 dev: config.Core.Dev, 61 65 avatar: config.Avatar, ··· 147 151 } 148 152 149 153 log.Printf("total templates loaded: %d", len(templates)) 154 + p.mu.Lock() 155 + defer p.mu.Unlock() 150 156 p.t = templates 151 157 } 152 158 ··· 207 213 } 208 214 209 215 // Update the template in the map 216 + p.mu.Lock() 217 + defer p.mu.Unlock() 210 218 p.t[name] = tmpl 211 219 log.Printf("template reloaded from disk: %s", name) 212 220 return nil ··· 221 229 } 222 230 } 223 231 232 + p.mu.RLock() 233 + defer p.mu.RUnlock() 224 234 tmpl, exists := p.t[templateName] 225 235 if !exists { 226 236 return fmt.Errorf("template not found: %s", templateName) ··· 278 288 } 279 289 280 290 func (p *Pages) Knots(w io.Writer, params KnotsParams) error { 281 - return p.execute("knots", w, params) 291 + return p.execute("knots/index", w, params) 282 292 } 283 293 284 294 type KnotParams struct { ··· 286 296 DidHandleMap map[string]string 287 297 Registration *db.Registration 288 298 Members []string 299 + Repos map[string][]db.Repo 289 300 IsOwner bool 290 301 } 291 302 292 303 func (p *Pages) Knot(w io.Writer, params KnotParams) error { 293 - return p.execute("knot", w, params) 304 + return p.execute("knots/dashboard", w, params) 305 + } 306 + 307 + type KnotListingParams struct { 308 + db.Registration 309 + } 310 + 311 + func (p *Pages) KnotListing(w io.Writer, params KnotListingParams) error { 312 + return p.executePlain("knots/fragments/knotListing", w, params) 313 + } 314 + 315 + type KnotListingFullParams struct { 316 + Registrations []db.Registration 317 + } 318 + 319 + func (p *Pages) KnotListingFull(w io.Writer, params KnotListingFullParams) error { 320 + return p.executePlain("knots/fragments/knotListingFull", w, params) 321 + } 322 + 323 + type KnotSecretParams struct { 324 + Secret string 325 + } 326 + 327 + func (p *Pages) KnotSecret(w io.Writer, params KnotSecretParams) error { 328 + return p.executePlain("knots/fragments/secret", w, params) 294 329 } 295 330 296 331 type SpindlesParams struct { ··· 502 537 Active string 503 538 EmailToDidOrHandle map[string]string 504 539 Pipeline *db.Pipeline 540 + DiffOpts types.DiffOpts 505 541 506 542 // singular because it's always going to be just one 507 543 VerifiedCommit commitverify.VerifiedCommits ··· 690 726 IssueOwnerHandle string 691 727 DidHandleMap map[string]string 692 728 729 + OrderedReactionKinds []db.ReactionKind 730 + Reactions map[db.ReactionKind]int 731 + UserReacted map[db.ReactionKind]bool 732 + 693 733 State string 694 734 } 695 735 736 + type ThreadReactionFragmentParams struct { 737 + ThreadAt syntax.ATURI 738 + Kind db.ReactionKind 739 + Count int 740 + IsReacted bool 741 + } 742 + 743 + func (p *Pages) ThreadReactionFragment(w io.Writer, params ThreadReactionFragmentParams) error { 744 + return p.executePlain("repo/fragments/reaction", w, params) 745 + } 746 + 696 747 func (p *Pages) RepoSingleIssue(w io.Writer, params RepoSingleIssueParams) error { 697 748 params.Active = "issues" 698 749 if params.Issue.Open { ··· 798 849 MergeCheck types.MergeCheckResponse 799 850 ResubmitCheck ResubmitResult 800 851 Pipelines map[string]db.Pipeline 852 + 853 + OrderedReactionKinds []db.ReactionKind 854 + Reactions map[db.ReactionKind]int 855 + UserReacted map[db.ReactionKind]bool 801 856 } 802 857 803 858 func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { ··· 806 861 } 807 862 808 863 type RepoPullPatchParams struct { 809 - LoggedInUser *oauth.User 810 - DidHandleMap map[string]string 811 - RepoInfo repoinfo.RepoInfo 812 - Pull *db.Pull 813 - Stack db.Stack 814 - Diff *types.NiceDiff 815 - Round int 816 - Submission *db.PullSubmission 864 + LoggedInUser *oauth.User 865 + DidHandleMap map[string]string 866 + RepoInfo repoinfo.RepoInfo 867 + Pull *db.Pull 868 + Stack db.Stack 869 + Diff *types.NiceDiff 870 + Round int 871 + Submission *db.PullSubmission 872 + OrderedReactionKinds []db.ReactionKind 873 + DiffOpts types.DiffOpts 817 874 } 818 875 819 876 // this name is a mouthful ··· 822 879 } 823 880 824 881 type RepoPullInterdiffParams struct { 825 - LoggedInUser *oauth.User 826 - DidHandleMap map[string]string 827 - RepoInfo repoinfo.RepoInfo 828 - Pull *db.Pull 829 - Round int 830 - Interdiff *patchutil.InterdiffResult 882 + LoggedInUser *oauth.User 883 + DidHandleMap map[string]string 884 + RepoInfo repoinfo.RepoInfo 885 + Pull *db.Pull 886 + Round int 887 + Interdiff *patchutil.InterdiffResult 888 + OrderedReactionKinds []db.ReactionKind 889 + DiffOpts types.DiffOpts 831 890 } 832 891 833 892 // this name is a mouthful ··· 918 977 Base string 919 978 Head string 920 979 Diff *types.NiceDiff 980 + DiffOpts types.DiffOpts 921 981 922 982 Active string 923 983 }
-98
appview/pages/templates/knot.html
··· 1 - {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 - 3 - {{ define "content" }} 4 - <div class="p-6"> 5 - <p class="text-xl font-bold dark:text-white">{{ .Registration.Domain }}</p> 6 - </div> 7 - 8 - <div class="flex flex-col"> 9 - {{ block "registration-info" . }} {{ end }} 10 - {{ block "members" . }} {{ end }} 11 - {{ block "add-member" . }} {{ end }} 12 - </div> 13 - {{ end }} 14 - 15 - {{ define "registration-info" }} 16 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 17 - <dl class="grid grid-cols-[auto_1fr] gap-x-4 dark:text-gray-200"> 18 - <dt class="font-bold">opened by</dt> 19 - <dd> 20 - <span> 21 - {{ index $.DidHandleMap .Registration.ByDid }} <span class="text-gray-500 dark:text-gray-400 font-mono">{{ .Registration.ByDid }}</span> 22 - </span> 23 - {{ if eq $.LoggedInUser.Did $.Registration.ByDid }} 24 - <span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded ml-2">you</span> 25 - {{ end }} 26 - </dd> 27 - 28 - <dt class="font-bold">opened</dt> 29 - <dd>{{ .Registration.Created | timeFmt }}</dd> 30 - 31 - {{ if .Registration.Registered }} 32 - <dt class="font-bold">registered</dt> 33 - <dd>{{ .Registration.Registered | timeFmt }}</dd> 34 - {{ else }} 35 - <dt class="font-bold">status</dt> 36 - <dd class="text-yellow-800 dark:text-yellow-200 bg-yellow-100 dark:bg-yellow-900 rounded px-2 py-1 inline-block"> 37 - Pending Registration 38 - </dd> 39 - {{ end }} 40 - </dl> 41 - 42 - {{ if not .Registration.Registered }} 43 - <div class="mt-4"> 44 - <button 45 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" 46 - hx-post="/knots/{{.Domain}}/init" 47 - hx-swap="none"> 48 - Initialize Registration 49 - </button> 50 - </div> 51 - {{ end }} 52 - </section> 53 - {{ end }} 54 - 55 - {{ define "members" }} 56 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">members</h2> 57 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 58 - {{ if .Registration.Registered }} 59 - <div id="member-list" class="flex flex-col gap-4"> 60 - {{ range $.Members }} 61 - <div class="inline-flex items-center gap-4"> 62 - {{ i "user" "w-4 h-4 dark:text-gray-300" }} 63 - <a href="/{{index $.DidHandleMap .}}" class="text-gray-900 dark:text-white">{{index $.DidHandleMap .}} 64 - <span class="text-gray-500 dark:text-gray-400 font-mono">{{.}}</span> 65 - </a> 66 - </div> 67 - {{ else }} 68 - <p class="text-gray-500 dark:text-gray-400">No members have been added yet.</p> 69 - {{ end }} 70 - </div> 71 - {{ else }} 72 - <p class="text-gray-500 dark:text-gray-400">Members can be added after registration is complete.</p> 73 - {{ end }} 74 - </section> 75 - {{ end }} 76 - 77 - {{ define "add-member" }} 78 - {{ if $.IsOwner }} 79 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">add member</h2> 80 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 81 - <form 82 - hx-put="/knots/{{.Registration.Domain}}/member" 83 - class="max-w-2xl space-y-4"> 84 - <input 85 - type="text" 86 - id="subject" 87 - name="subject" 88 - placeholder="did or handle" 89 - required 90 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400"/> 91 - 92 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600" type="submit">add member</button> 93 - 94 - <div id="add-member-error" class="error dark:text-red-400"></div> 95 - </form> 96 - </section> 97 - {{ end }} 98 - {{ end }}
+63
appview/pages/templates/knots/dashboard.html
··· 1 + {{ define "title" }}{{ .Registration.Domain }}{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <div class="flex justify-between items-center"> 6 + <div id="left-side" class="flex gap-2 items-center"> 7 + <h1 class="text-xl font-bold dark:text-white"> 8 + {{ .Registration.Domain }} 9 + </h1> 10 + <span class="text-gray-500 text-base"> 11 + {{ template "repo/fragments/shortTimeAgo" .Registration.Created }} 12 + </span> 13 + </div> 14 + <div id="right-side" class="flex gap-2"> 15 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2" }} 16 + {{ if .Registration.Registered }} 17 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 18 + {{ template "knots/fragments/addMemberModal" .Registration }} 19 + {{ else }} 20 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 21 + {{ end }} 22 + </div> 23 + </div> 24 + <div id="operation-error" class="dark:text-red-400"></div> 25 + </div> 26 + 27 + {{ if .Members }} 28 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 29 + <div class="flex flex-col gap-2"> 30 + {{ block "knotMember" . }} {{ end }} 31 + </div> 32 + </section> 33 + {{ end }} 34 + {{ end }} 35 + 36 + {{ define "knotMember" }} 37 + {{ range .Members }} 38 + <div> 39 + <div class="flex justify-between items-center"> 40 + <div class="flex items-center gap-2"> 41 + {{ i "user" "size-4" }} 42 + {{ $user := index $.DidHandleMap . }} 43 + <a href="/{{ $user }}">{{ $user }} <span class="ml-2 font-mono text-gray-500">{{.}}</span></a> 44 + </div> 45 + </div> 46 + <div class="ml-2 pl-2 pt-2 border-l border-gray-200 dark:border-gray-700"> 47 + {{ $repos := index $.Repos . }} 48 + {{ range $repos }} 49 + <div class="flex gap-2 items-center"> 50 + {{ i "book-marked" "size-4" }} 51 + <a href="/{{ .Did }}/{{ .Name }}"> 52 + {{ .Name }} 53 + </a> 54 + </div> 55 + {{ else }} 56 + <div class="text-gray-500 dark:text-gray-400"> 57 + No repositories created yet. 58 + </div> 59 + {{ end }} 60 + </div> 61 + </div> 62 + {{ end }} 63 + {{ end }}
+58
appview/pages/templates/knots/fragments/addMemberModal.html
··· 1 + {{ define "knots/fragments/addMemberModal" }} 2 + <button 3 + class="btn gap-2 group" 4 + title="Add member to this spindle" 5 + popovertarget="add-member-{{ .Id }}" 6 + popovertargetaction="toggle" 7 + > 8 + {{ i "user-plus" "w-5 h-5" }} 9 + <span class="hidden md:inline">add member</span> 10 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 11 + </button> 12 + 13 + <div 14 + id="add-member-{{ .Id }}" 15 + popover 16 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded drop-shadow dark:text-white"> 17 + {{ block "addKnotMemberPopover" . }} {{ end }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ define "addKnotMemberPopover" }} 22 + <form 23 + hx-put="/knots/{{ .Domain }}/member" 24 + hx-indicator="#spinner" 25 + hx-swap="none" 26 + class="flex flex-col gap-2" 27 + > 28 + <label for="member-did-{{ .Id }}" class="uppercase p-0"> 29 + ADD MEMBER 30 + </label> 31 + <p class="text-sm text-gray-500 dark:text-gray-400">Members can create repositories on this knot.</p> 32 + <input 33 + type="text" 34 + id="member-did-{{ .Id }}" 35 + name="subject" 36 + required 37 + placeholder="@foo.bsky.social" 38 + /> 39 + <div class="flex gap-2 pt-2"> 40 + <button 41 + type="button" 42 + popovertarget="add-member-{{ .Id }}" 43 + popovertargetaction="hide" 44 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 45 + > 46 + {{ i "x" "size-4" }} cancel 47 + </button> 48 + <button type="submit" class="btn w-1/2 flex items-center"> 49 + <span class="inline-flex gap-2 items-center">{{ i "user-plus" "size-4" }} add</span> 50 + <span id="spinner" class="group"> 51 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 52 + </span> 53 + </button> 54 + </div> 55 + <div id="add-member-error-{{ .Id }}" class="text-red-500 dark:text-red-400"></div> 56 + </form> 57 + {{ end }} 58 +
+51
appview/pages/templates/knots/fragments/knotListing.html
··· 1 + {{ define "knots/fragments/knotListing" }} 2 + <div 3 + id="knot-{{.Id}}" 4 + hx-swap-oob="true" 5 + class="flex items-center justify-between p-2 border-b border-gray-200 dark:border-gray-700"> 6 + {{ block "listLeftSide" . }} {{ end }} 7 + {{ block "listRightSide" . }} {{ end }} 8 + </div> 9 + {{ end }} 10 + 11 + {{ define "listLeftSide" }} 12 + <div class="hover:no-underline flex items-center gap-2 min-w-0 max-w-[60%]"> 13 + {{ i "hard-drive" "w-4 h-4" }} 14 + {{ if .Registered }} 15 + <a href="/knots/{{ .Domain }}"> 16 + {{ .Domain }} 17 + </a> 18 + {{ else }} 19 + {{ .Domain }} 20 + {{ end }} 21 + <span class="text-gray-500"> 22 + {{ template "repo/fragments/shortTimeAgo" .Created }} 23 + </span> 24 + </div> 25 + {{ end }} 26 + 27 + {{ define "listRightSide" }} 28 + <div id="right-side" class="flex gap-2"> 29 + {{ $style := "px-2 py-1 rounded flex items-center flex-shrink-0 gap-2 text-sm" }} 30 + {{ if .Registered }} 31 + <span class="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 {{$style}}">{{ i "shield-check" "w-4 h-4" }} verified</span> 32 + {{ template "knots/fragments/addMemberModal" . }} 33 + {{ else }} 34 + <span class="bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 {{$style}}">{{ i "shield-off" "w-4 h-4" }} pending</span> 35 + {{ block "initializeButton" . }} {{ end }} 36 + {{ end }} 37 + </div> 38 + {{ end }} 39 + 40 + {{ define "initializeButton" }} 41 + <button 42 + class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 43 + hx-post="/knots/{{ .Domain }}/init" 44 + hx-swap="none" 45 + > 46 + {{ i "square-play" "w-5 h-5" }} 47 + <span class="hidden md:inline">initialize</span> 48 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 49 + </button> 50 + {{ end }} 51 +
+18
appview/pages/templates/knots/fragments/knotListingFull.html
··· 1 + {{ define "knots/fragments/knotListingFull" }} 2 + <section 3 + id="knot-listing-full" 4 + hx-swap-oob="true" 5 + class="rounded w-full flex flex-col gap-2"> 6 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your knots</h2> 7 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full"> 8 + {{ range $knot := .Registrations }} 9 + {{ template "knots/fragments/knotListing" . }} 10 + {{ else }} 11 + <div class="flex items-center justify-center p-2 border-b border-gray-200 dark:border-gray-700 text-gray-500"> 12 + no knots registered yet 13 + </div> 14 + {{ end }} 15 + </div> 16 + <div id="operation-error" class="text-red-500 dark:text-red-400"></div> 17 + </section> 18 + {{ end }}
+10
appview/pages/templates/knots/fragments/secret.html
··· 1 + {{ define "knots/fragments/secret" }} 2 + <div 3 + id="secret" 4 + hx-swap-oob="true" 5 + class="bg-gray-50 dark:bg-gray-700 border border-black dark:border-gray-500 rounded px-6 py-2 w-full lg:w-3xl"> 6 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">generated secret</h2> 7 + <p class="pb-2">Configure your knot to use this secret, and then hit initialize.</p> 8 + <span class="font-mono overflow-x">{{ .Secret }}</span> 9 + </div> 10 + {{ end }}
+69
appview/pages/templates/knots/index.html
··· 1 + {{ define "title" }}knots{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="px-6 py-4"> 5 + <h1 class="text-xl font-bold dark:text-white">Knots</h1> 6 + </div> 7 + 8 + <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 + <div class="flex flex-col gap-6"> 10 + {{ block "about" . }} {{ end }} 11 + {{ template "knots/fragments/knotListingFull" . }} 12 + {{ block "register" . }} {{ end }} 13 + </div> 14 + </section> 15 + {{ end }} 16 + 17 + {{ define "about" }} 18 + <section class="rounded flex flex-col gap-2"> 19 + <p class="dark:text-gray-300"> 20 + Knots are lightweight headless servers that enable users to host Git repositories with ease. 21 + Knots are designed for either single or multi-tenant use which is perfect for self-hosting on a Raspberry Pi at home, or larger โ€œcommunityโ€ servers. 22 + When creating a repository, you can choose a knot to store it on. 23 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/knot-hosting.md"> 24 + Checkout the documentation if you're interested in self-hosting. 25 + </a> 26 + </p> 27 + </section> 28 + {{ end }} 29 + 30 + {{ define "register" }} 31 + <section class="rounded max-w-2xl flex flex-col gap-2"> 32 + <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">register a knot</h2> 33 + <p class="mb-2 dark:text-gray-300">Enter the hostname of your knot to generate a key.</p> 34 + <form 35 + hx-post="/knots/key" 36 + class="space-y-4" 37 + hx-indicator="#register-button" 38 + hx-swap="none" 39 + > 40 + <div class="flex gap-2"> 41 + <input 42 + type="text" 43 + id="domain" 44 + name="domain" 45 + placeholder="knot.example.com" 46 + required 47 + class="flex-1 w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400 px-3 py-2 border rounded" 48 + > 49 + <button 50 + type="submit" 51 + id="register-button" 52 + class="btn rounded flex items-center py-2 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 group" 53 + > 54 + <span class="inline-flex items-center gap-2"> 55 + {{ i "plus" "w-4 h-4" }} 56 + generate 57 + </span> 58 + <span class="pl-2 hidden group-[.htmx-request]:inline"> 59 + {{ i "loader-circle" "w-4 h-4 animate-spin" }} 60 + </span> 61 + </button> 62 + </div> 63 + 64 + <div id="registration-error" class="error dark:text-red-400"></div> 65 + </form> 66 + 67 + <div id="secret"></div> 68 + </section> 69 + {{ end }}
-93
appview/pages/templates/knots.html
··· 1 - {{ define "title" }}knots{{ end }} 2 - {{ define "content" }} 3 - <div class="p-6"> 4 - <p class="text-xl font-bold dark:text-white">Knots</p> 5 - </div> 6 - <div class="flex flex-col"> 7 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">register a knot</h2> 8 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 9 - <p class="mb-8 dark:text-gray-300">Generate a key to initialize your knot server.</p> 10 - <form 11 - hx-post="/knots/key" 12 - class="max-w-2xl mb-8 space-y-4" 13 - hx-indicator="#generate-knot-key-spinner" 14 - > 15 - <input 16 - type="text" 17 - id="domain" 18 - name="domain" 19 - placeholder="knot.example.com" 20 - required 21 - class="w-full dark:bg-gray-700 dark:text-white dark:border-gray-600 dark:placeholder-gray-400" 22 - > 23 - <button class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex items-center" type="submit"> 24 - <span>generate key</span> 25 - <span id="generate-knot-key-spinner" class="group"> 26 - {{ i "loader-circle" "pl-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 27 - </span> 28 - </button> 29 - <div id="settings-knots-error" class="error dark:text-red-400"></div> 30 - </form> 31 - </section> 32 - 33 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">my knots</h2> 34 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 35 - <div id="knots-list" class="flex flex-col gap-6 mb-8"> 36 - {{ range .Registrations }} 37 - {{ if .Registered }} 38 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 39 - <div class="flex flex-col gap-1"> 40 - <div class="inline-flex items-center gap-4"> 41 - {{ i "git-branch" "w-3 h-3 dark:text-gray-300" }} 42 - <a href="/knots/{{ .Domain }}"> 43 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 44 - </a> 45 - </div> 46 - <p class="text-sm text-gray-500 dark:text-gray-400">owned by {{ .ByDid }}</p> 47 - <p class="text-sm text-gray-500 dark:text-gray-400">registered {{ .Registered | timeFmt }}</p> 48 - </div> 49 - </div> 50 - {{ end }} 51 - {{ else }} 52 - <p class="text-sm text-gray-500 dark:text-gray-400">No knots registered</p> 53 - {{ end }} 54 - </div> 55 - </section> 56 - 57 - <h2 class="text-sm font-bold py-2 px-6 uppercase dark:text-gray-300">pending registrations</h2> 58 - <section class="rounded bg-white dark:bg-gray-800 drop-shadow-sm px-6 py-4 mb-6 w-full lg:w-fit"> 59 - <div id="pending-knots-list" class="flex flex-col gap-6 mb-8"> 60 - {{ range .Registrations }} 61 - {{ if not .Registered }} 62 - <div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-4"> 63 - <div class="flex flex-col gap-1"> 64 - <div class="inline-flex items-center gap-4"> 65 - <p class="font-bold dark:text-white">{{ .Domain }}</p> 66 - <div class="inline-flex items-center gap-1"> 67 - <span class="text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 px-2 py-1 rounded"> 68 - pending 69 - </span> 70 - </div> 71 - </div> 72 - <p class="text-sm text-gray-500 dark:text-gray-400">opened by {{ .ByDid }}</p> 73 - <p class="text-sm text-gray-500 dark:text-gray-400">created {{ .Created | timeFmt }}</p> 74 - </div> 75 - <div class="flex gap-2 items-center"> 76 - <button 77 - class="btn dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 flex gap-2 items-center group" 78 - hx-post="/knots/{{ .Domain }}/init" 79 - > 80 - {{ i "square-play" "w-5 h-5" }} 81 - <span class="hidden md:inline">initialize</span> 82 - {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 83 - </button> 84 - </div> 85 - </div> 86 - {{ end }} 87 - {{ else }} 88 - <p class="text-sm text-gray-500 dark:text-gray-400">No pending registrations</p> 89 - {{ end }} 90 - </div> 91 - </section> 92 - </div> 93 - {{ end }}
+49 -12
appview/pages/templates/layouts/base.html
··· 15 15 {{ block "extrameta" . }}{{ end }} 16 16 </head> 17 17 <body class="bg-slate-100 dark:bg-gray-900 dark:text-white transition-colors duration-200"> 18 - <div class="container mx-auto px-1 md:pt-4 min-h-screen flex flex-col"> 19 - <header style="z-index: 20"> 20 - {{ block "topbar" . }} 21 - {{ template "layouts/topbar" . }} 22 - {{ end }} 23 - </header> 24 - <main class="content grow">{{ block "content" . }}{{ end }}</main> 25 - <footer class="mt-16"> 26 - {{ block "footer" . }} 27 - {{ template "layouts/footer" . }} 28 - {{ end }} 29 - </footer> 18 + <div class="px-1" style="z-index: 20"> 19 + {{ block "topbarLayout" . }} 20 + <div class="grid grid-cols-1 md:grid-cols-12"> 21 + <header class="col-span-1 md:col-start-3 md:col-span-8" style="z-index: 20;"> 22 + {{ template "layouts/topbar" . }} 23 + </header> 24 + </div> 25 + {{ end }} 26 + </div> 27 + 28 + <div class="px-1 flex flex-col min-h-screen gap-4"> 29 + {{ block "contentLayout" . }} 30 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 31 + <div class="col-span-1 md:col-span-2"> 32 + {{ block "contentLeft" . }} {{ end }} 33 + </div> 34 + <main class="col-span-1 md:col-span-8"> 35 + {{ block "content" . }}{{ end }} 36 + </main> 37 + <div class="col-span-1 md:col-span-2"> 38 + {{ block "contentRight" . }} {{ end }} 39 + </div> 40 + </div> 41 + {{ end }} 42 + 43 + {{ block "contentAfterLayout" . }} 44 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 45 + <div class="col-span-1 md:col-span-2"> 46 + {{ block "contentAfterLeft" . }} {{ end }} 47 + </div> 48 + <main class="col-span-1 md:col-span-8"> 49 + {{ block "contentAfter" . }}{{ end }} 50 + </main> 51 + <div class="col-span-1 md:col-span-2"> 52 + {{ block "contentAfterRight" . }} {{ end }} 53 + </div> 54 + </div> 55 + {{ end }} 30 56 </div> 57 + 58 + <div class="px-1 mt-16"> 59 + {{ block "footerLayout" . }} 60 + <div class="grid grid-cols-1 md:grid-cols-12"> 61 + <footer class="col-span-1 md:col-start-3 md:col-span-8"> 62 + {{ template "layouts/footer" . }} 63 + </footer> 64 + </div> 65 + {{ end }} 66 + </div> 67 + 31 68 </body> 32 69 </html> 33 70 {{ end }}
+3 -3
appview/pages/templates/layouts/repobase.html
··· 25 25 </section> 26 26 27 27 <section 28 - class="min-h-screen w-full flex flex-col drop-shadow-sm" 28 + class="w-full flex flex-col drop-shadow-sm" 29 29 > 30 30 <nav class="w-full pl-4 overflow-auto"> 31 31 <div class="flex z-60"> ··· 47 47 {{ if eq $.Active $key }} 48 48 {{ $activeTabStyles }} 49 49 {{ else }} 50 - group-hover:bg-gray-200 dark:group-hover:bg-gray-700 50 + group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25 51 51 {{ end }} 52 52 " 53 53 > ··· 64 64 </div> 65 65 </nav> 66 66 <section 67 - class="bg-white dark:bg-gray-800 p-6 rounded relative w-full drop-shadow-sm dark:text-white" 67 + class="bg-white dark:bg-gray-800 p-6 rounded relative w-full dark:text-white" 68 68 > 69 69 {{ block "repoContent" . }}{{ end }} 70 70 </section>
+5 -4
appview/pages/templates/layouts/topbar.html
··· 1 1 {{ define "layouts/topbar" }} 2 2 <nav class="space-x-4 mb-4 px-6 py-2 rounded bg-white dark:bg-gray-800 dark:text-white drop-shadow-sm"> 3 - <div class="container flex justify-between p-0 items-center"> 3 + <div class="flex justify-between p-0 items-center"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 6 tangled<sub>alpha</sub> ··· 36 36 {{ define "dropDown" }} 37 37 <details class="relative inline-block text-left"> 38 38 <summary 39 - class="cursor-pointer list-none" 39 + class="cursor-pointer list-none flex items-center" 40 40 > 41 - {{ didOrHandle .Did .Handle }} 41 + {{ $user := didOrHandle .Did .Handle }} 42 + {{ template "user/fragments/picHandle" $user }} 42 43 </summary> 43 44 <div 44 45 class="absolute flex flex-col right-0 mt-4 p-4 rounded w-48 bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700" 45 46 > 46 - <a href="/{{ didOrHandle .Did .Handle }}">profile</a> 47 + <a href="/{{ $user }}">profile</a> 47 48 <a href="/knots">knots</a> 48 49 <a href="/spindles">spindles</a> 49 50 <a href="/settings">settings</a>
+2 -2
appview/pages/templates/repo/branches.html
··· 59 59 </td> 60 60 <td class="py-3 whitespace-nowrap text-gray-500 dark:text-gray-400"> 61 61 {{ if .Commit }} 62 - {{ .Commit.Committer.When | timeFmt }} 62 + {{ template "repo/fragments/time" .Commit.Committer.When }} 63 63 {{ end }} 64 64 </td> 65 65 </tr> ··· 98 98 </a> 99 99 </span> 100 100 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 101 - <span>{{ .Commit.Committer.When | timeFmt }}</span> 101 + {{ template "repo/fragments/time" .Commit.Committer.When }} 102 102 </div> 103 103 {{ end }} 104 104 </div>
+37 -6
appview/pages/templates/repo/commit.html
··· 34 34 <a href="mailto:{{ $commit.Author.Email }}" class="no-underline hover:underline text-gray-500 dark:text-gray-300">{{ $commit.Author.Name }}</a> 35 35 {{ end }} 36 36 <span class="px-1 select-none before:content-['\00B7']"></span> 37 - {{ timeFmt $commit.Author.When }} 37 + {{ template "repo/fragments/time" $commit.Author.When }} 38 38 <span class="px-1 select-none before:content-['\00B7']"></span> 39 39 </p> 40 40 ··· 59 59 <div class="flex items-center gap-2 my-2"> 60 60 {{ i "user" "w-4 h-4" }} 61 61 {{ $committerDidOrHandle := index $.EmailToDidOrHandle $commit.Committer.Email }} 62 - <a href="/{{ $committerDidOrHandle }}">{{ $committerDidOrHandle }}</a> 62 + <a href="/{{ $committerDidOrHandle }}">{{ template "user/fragments/picHandleLink" $committerDidOrHandle }}</a> 63 63 </div> 64 64 <div class="my-1 pt-2 text-xs border-t"> 65 65 <div class="text-gray-600 dark:text-gray-300">SSH Key Fingerprint:</div> ··· 77 77 </div> 78 78 79 79 </section> 80 + {{end}} 81 + 82 + {{ define "topbarLayout" }} 83 + <header style="z-index: 20;"> 84 + {{ template "layouts/topbar" . }} 85 + </header> 86 + {{ end }} 80 87 88 + {{ define "contentLayout" }} 89 + {{ block "content" . }}{{ end }} 90 + {{ end }} 91 + 92 + {{ define "contentAfterLayout" }} 93 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 94 + <div class="col-span-1 md:col-span-2"> 95 + {{ block "contentAfterLeft" . }} {{ end }} 96 + </div> 97 + <main class="col-span-1 md:col-span-10"> 98 + {{ block "contentAfter" . }}{{ end }} 99 + </main> 100 + </div> 101 + {{ end }} 102 + 103 + {{ define "footerLayout" }} 104 + {{ template "layouts/footer" . }} 105 + {{ end }} 106 + 107 + {{ define "contentAfter" }} 108 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 81 109 {{end}} 82 110 83 - {{ define "repoAfter" }} 84 - <div class="-z-[9999]"> 85 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 86 - </div> 111 + {{ define "contentAfterLeft" }} 112 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 113 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 114 + </div> 115 + <div class="sticky top-0 mt-4"> 116 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 117 + </div> 87 118 {{end}}
+34 -2
appview/pages/templates/repo/compare/compare.html
··· 10 10 {{ end }} 11 11 {{ end }} 12 12 13 - {{ define "repoAfter" }} 14 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 13 + {{ define "topbarLayout" }} 14 + {{ template "layouts/topbar" . }} 15 + {{ end }} 16 + 17 + {{ define "contentLayout" }} 18 + {{ block "content" . }}{{ end }} 15 19 {{ end }} 20 + 21 + {{ define "contentAfterLayout" }} 22 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 23 + <div class="col-span-1 md:col-span-2"> 24 + {{ block "contentAfterLeft" . }} {{ end }} 25 + </div> 26 + <main class="col-span-1 md:col-span-10"> 27 + {{ block "contentAfter" . }}{{ end }} 28 + </main> 29 + </div> 30 + {{ end }} 31 + 32 + {{ define "footerLayout" }} 33 + {{ template "layouts/footer" . }} 34 + {{ end }} 35 + 36 + {{ define "contentAfter" }} 37 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 38 + {{end}} 39 + 40 + {{ define "contentAfterLeft" }} 41 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 42 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 43 + </div> 44 + <div class="sticky top-0 mt-4"> 45 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 46 + </div> 47 + {{end}}
+1 -1
appview/pages/templates/repo/compare/new.html
··· 19 19 <a href="/{{ $.RepoInfo.FullName }}/compare?head={{ $br.Name | urlquery }}" class="no-underline hover:no-underline"> 20 20 <div class="flex items-center justify-between p-2"> 21 21 {{ $br.Name }} 22 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 22 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 23 23 </div> 24 24 </a> 25 25 {{ end }}
+1 -1
appview/pages/templates/repo/empty.html
··· 17 17 <a href="/{{ $.RepoInfo.FullName }}/tree/{{$br.Name | urlquery }}" class="no-underline hover:no-underline"> 18 18 <div class="flex items-center justify-between p-2"> 19 19 {{ $br.Name }} 20 - <time class="text-gray-500 dark:text-gray-400">{{ timeFmt $br.Commit.Committer.When }}</time> 20 + <span class="text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $br.Commit.Committer.When }}</span> 21 21 </div> 22 22 </a> 23 23 {{ end }}
+2 -2
appview/pages/templates/repo/fragments/artifact.html
··· 10 10 </div> 11 11 12 12 <div id="right-side" class="text-gray-500 dark:text-gray-400 flex items-center flex-shrink-0 gap-2 text-sm"> 13 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class="hidden md:inline">{{ timeFmt .Artifact.CreatedAt }}</span> 14 - <span title="{{ longTimeFmt .Artifact.CreatedAt }}" class=" md:hidden">{{ shortTimeFmt .Artifact.CreatedAt }}</span> 13 + <span class="hidden md:inline">{{ template "repo/fragments/time" .Artifact.CreatedAt }}</span> 14 + <span class=" md:hidden">{{ template "repo/fragments/shortTime" .Artifact.CreatedAt }}</span> 15 15 16 16 <span class="select-none after:content-['ยท'] hidden md:inline"></span> 17 17 <span class="truncate max-w-[100px] hidden md:inline">{{ .Artifact.MimeType }}</span>
+90 -145
appview/pages/templates/repo/fragments/diff.html
··· 1 1 {{ define "repo/fragments/diff" }} 2 - {{ $repo := index . 0 }} 3 - {{ $diff := index . 1 }} 4 - {{ $commit := $diff.Commit }} 5 - {{ $stat := $diff.Stat }} 6 - {{ $fileTree := fileTree $diff.ChangedFiles }} 7 - {{ $diff := $diff.Diff }} 2 + {{ $repo := index . 0 }} 3 + {{ $diff := index . 1 }} 4 + {{ $opts := index . 2 }} 8 5 6 + {{ $commit := $diff.Commit }} 7 + {{ $diff := $diff.Diff }} 8 + {{ $isSplit := $opts.Split }} 9 9 {{ $this := $commit.This }} 10 10 {{ $parent := $commit.Parent }} 11 + {{ $last := sub (len $diff) 1 }} 11 12 12 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 13 - <div class="diff-stat"> 14 - <div class="flex gap-2 items-center"> 15 - <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 16 - {{ block "statPill" $stat }} {{ end }} 17 - </div> 18 - {{ block "fileTree" $fileTree }} {{ end }} 19 - </div> 20 - </section> 13 + <div class="flex flex-col gap-4"> 14 + {{ range $idx, $hunk := $diff }} 15 + {{ with $hunk }} 16 + <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 17 + <div id="file-{{ .Name.New }}"> 18 + <div id="diff-file"> 19 + <details open> 20 + <summary class="list-none cursor-pointer sticky top-0"> 21 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 22 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 23 + <div class="flex gap-1 items-center"> 24 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 25 + {{ if .IsNew }} 26 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 27 + {{ else if .IsDelete }} 28 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 29 + {{ else if .IsCopy }} 30 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 31 + {{ else if .IsRename }} 32 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 33 + {{ else }} 34 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 35 + {{ end }} 21 36 22 - {{ $last := sub (len $diff) 1 }} 23 - {{ range $idx, $hunk := $diff }} 24 - {{ with $hunk }} 25 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 26 - <div id="file-{{ .Name.New }}"> 27 - <div id="diff-file"> 28 - <details open> 29 - <summary class="list-none cursor-pointer sticky top-0"> 30 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 31 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 32 - <div class="flex gap-1 items-center"> 33 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 34 - {{ if .IsNew }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">ADDED</span> 36 - {{ else if .IsDelete }} 37 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">DELETED</span> 38 - {{ else if .IsCopy }} 39 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">COPIED</span> 40 - {{ else if .IsRename }} 41 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">RENAMED</span> 42 - {{ else }} 43 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">MODIFIED</span> 44 - {{ end }} 37 + {{ template "repo/fragments/diffStatPill" .Stats }} 38 + </div> 39 + 40 + <div class="flex gap-2 items-center overflow-x-auto"> 41 + {{ if .IsDelete }} 42 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 43 + {{ .Name.Old }} 44 + </a> 45 + {{ else if (or .IsCopy .IsRename) }} 46 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 47 + {{ .Name.Old }} 48 + </a> 49 + {{ i "arrow-right" "w-4 h-4" }} 50 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 51 + {{ .Name.New }} 52 + </a> 53 + {{ else }} 54 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 55 + {{ .Name.New }} 56 + </a> 57 + {{ end }} 58 + </div> 59 + </div> 60 + 61 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 62 + <div id="right-side-items" class="p-2 flex items-center"> 63 + <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 64 + {{ if gt $idx 0 }} 65 + {{ $prev := index $diff (sub $idx 1) }} 66 + <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 67 + {{ end }} 68 + 69 + {{ if lt $idx $last }} 70 + {{ $next := index $diff (add $idx 1) }} 71 + <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 72 + {{ end }} 73 + </div> 45 74 46 - {{ block "statPill" .Stats }} {{ end }} 47 75 </div> 76 + </summary> 48 77 49 - <div class="flex gap-2 items-center overflow-x-auto"> 50 - {{ if .IsDelete }} 51 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this }}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.Old }}"{{end}}> 52 - {{ .Name.Old }} 53 - </a> 54 - {{ else if (or .IsCopy .IsRename) }} 55 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $parent}}href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}"{{end}}> 56 - {{ .Name.Old }} 57 - </a> 58 - {{ i "arrow-right" "w-4 h-4" }} 59 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 60 - {{ .Name.New }} 61 - </a> 78 + <div class="transition-all duration-700 ease-in-out"> 79 + {{ if .IsDelete }} 80 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 81 + This file has been deleted. 82 + </p> 83 + {{ else if .IsCopy }} 84 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 85 + This file has been copied. 86 + </p> 87 + {{ else if .IsBinary }} 88 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 89 + This is a binary file and will not be displayed. 90 + </p> 91 + {{ else }} 92 + {{ if $isSplit }} 93 + {{- template "repo/fragments/splitDiff" .Split -}} 62 94 {{ else }} 63 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" {{if $this}}href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}"{{end}}> 64 - {{ .Name.New }} 65 - </a> 95 + {{- template "repo/fragments/unifiedDiff" . -}} 66 96 {{ end }} 67 - </div> 97 + {{- end -}} 68 98 </div> 69 99 70 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 71 - <div id="right-side-items" class="p-2 flex items-center"> 72 - <a title="top of file" href="#file-{{ .Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 73 - {{ if gt $idx 0 }} 74 - {{ $prev := index $diff (sub $idx 1) }} 75 - <a title="previous file" href="#file-{{ $prev.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 76 - {{ end }} 100 + </details> 77 101 78 - {{ if lt $idx $last }} 79 - {{ $next := index $diff (add $idx 1) }} 80 - <a title="next file" href="#file-{{ $next.Name.New }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 81 - {{ end }} 82 - </div> 83 - 84 - </div> 85 - </summary> 86 - 87 - <div class="transition-all duration-700 ease-in-out"> 88 - {{ if .IsDelete }} 89 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 90 - This file has been deleted. 91 - </p> 92 - {{ else if .IsCopy }} 93 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 94 - This file has been copied. 95 - </p> 96 - {{ else if .IsBinary }} 97 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 98 - This is a binary file and will not be displayed. 99 - </p> 100 - {{ else }} 101 - {{ $name := .Name.New }} 102 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 103 - {{- $oldStart := .OldPosition -}} 104 - {{- $newStart := .NewPosition -}} 105 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 106 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 107 - {{- $lineNrSepStyle1 := "" -}} 108 - {{- $lineNrSepStyle2 := "pr-2" -}} 109 - {{- range .Lines -}} 110 - {{- if eq .Op.String "+" -}} 111 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 112 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 113 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 114 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 115 - <div class="px-2">{{ .Line }}</div> 116 - </div> 117 - {{- $newStart = add64 $newStart 1 -}} 118 - {{- end -}} 119 - {{- if eq .Op.String "-" -}} 120 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 121 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 122 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 123 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 124 - <div class="px-2">{{ .Line }}</div> 125 - </div> 126 - {{- $oldStart = add64 $oldStart 1 -}} 127 - {{- end -}} 128 - {{- if eq .Op.String " " -}} 129 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 130 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 131 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 132 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 133 - <div class="px-2">{{ .Line }}</div> 134 - </div> 135 - {{- $newStart = add64 $newStart 1 -}} 136 - {{- $oldStart = add64 $oldStart 1 -}} 137 - {{- end -}} 138 - {{- end -}} 139 - {{- end -}}</div></div></pre> 140 - {{- end -}} 141 102 </div> 142 - 143 - </details> 144 - 145 - </div> 146 - </div> 147 - </section> 148 - {{ end }} 149 - {{ end }} 150 - {{ end }} 151 - 152 - {{ define "statPill" }} 153 - <div class="flex items-center font-mono text-sm"> 154 - {{ if and .Insertions .Deletions }} 155 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 156 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 157 - {{ else if .Insertions }} 158 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 159 - {{ else if .Deletions }} 160 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 103 + </div> 104 + </section> 105 + {{ end }} 161 106 {{ end }} 162 107 </div> 163 108 {{ end }}
+13
appview/pages/templates/repo/fragments/diffChangedFiles.html
··· 1 + {{ define "repo/fragments/diffChangedFiles" }} 2 + {{ $stat := .Stat }} 3 + {{ $fileTree := fileTree .ChangedFiles }} 4 + <section class="overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto md:min-h-screen rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 5 + <div class="diff-stat"> 6 + <div class="flex gap-2 items-center"> 7 + <strong class="text-sm uppercase dark:text-gray-200">Changed files</strong> 8 + {{ template "repo/fragments/diffStatPill" $stat }} 9 + </div> 10 + {{ template "repo/fragments/fileTree" $fileTree }} 11 + </div> 12 + </section> 13 + {{ end }}
+28
appview/pages/templates/repo/fragments/diffOpts.html
··· 1 + {{ define "repo/fragments/diffOpts" }} 2 + <section class="flex flex-col gap-2 overflow-x-auto text-sm px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 3 + <strong class="text-sm uppercase dark:text-gray-200">options</strong> 4 + {{ $active := "unified" }} 5 + {{ if .Split }} 6 + {{ $active = "split" }} 7 + {{ end }} 8 + {{ $values := list "unified" "split" }} 9 + {{ block "tabSelector" (dict "Name" "diff" "Values" $values "Active" $active) }} {{ end }} 10 + </section> 11 + {{ end }} 12 + 13 + {{ define "tabSelector" }} 14 + {{ $name := .Name }} 15 + {{ $all := .Values }} 16 + {{ $active := .Active }} 17 + <div class="flex justify-between divide-x divide-gray-200 dark:divide-gray-700 rounded border border-gray-200 dark:border-gray-700 overflow-hidden"> 18 + {{ $activeTab := "bg-white dark:bg-gray-700 shadow-sm" }} 19 + {{ $inactiveTab := "bg-gray-100 dark:bg-gray-800 shadow-inner" }} 20 + {{ range $index, $value := $all }} 21 + {{ $isActive := eq $value $active }} 22 + <a href="?{{ $name }}={{ $value }}" 23 + class="py-2 text-sm w-full block hover:no-underline text-center {{ if $isActive }} {{$activeTab }} {{ else }} {{ $inactiveTab }} {{ end }}"> 24 + {{ $value }} 25 + </a> 26 + {{ end }} 27 + </div> 28 + {{ end }}
+13
appview/pages/templates/repo/fragments/diffStatPill.html
··· 1 + {{ define "repo/fragments/diffStatPill" }} 2 + <div class="flex items-center font-mono text-sm"> 3 + {{ if and .Insertions .Deletions }} 4 + <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 5 + <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 6 + {{ else if .Insertions }} 7 + <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 8 + {{ else if .Deletions }} 9 + <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 10 + {{ end }} 11 + </div> 12 + {{ end }} 13 +
+27
appview/pages/templates/repo/fragments/fileTree.html
··· 1 + {{ define "repo/fragments/fileTree" }} 2 + {{ if and .Name .IsDirectory }} 3 + <details open> 4 + <summary class="cursor-pointer list-none pt-1"> 5 + <span class="tree-directory inline-flex items-center gap-2 "> 6 + {{ i "folder" "size-4 fill-current" }} 7 + <span class="filename text-black dark:text-white">{{ .Name }}</span> 8 + </span> 9 + </summary> 10 + <div class="ml-1 pl-2 border-l border-gray-200 dark:border-gray-700"> 11 + {{ range $child := .Children }} 12 + {{ template "repo/fragments/fileTree" $child }} 13 + {{ end }} 14 + </div> 15 + </details> 16 + {{ else if .Name }} 17 + <div class="tree-file flex items-center gap-2 pt-1"> 18 + {{ i "file" "size-4" }} 19 + <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 + </div> 21 + {{ else }} 22 + {{ range $child := .Children }} 23 + {{ template "repo/fragments/fileTree" $child }} 24 + {{ end }} 25 + {{ end }} 26 + {{ end }} 27 +
-27
appview/pages/templates/repo/fragments/filetree.html
··· 1 - {{ define "fileTree" }} 2 - {{ if and .Name .IsDirectory }} 3 - <details open> 4 - <summary class="cursor-pointer list-none pt-1"> 5 - <span class="tree-directory inline-flex items-center gap-2 "> 6 - {{ i "folder" "size-4 fill-current" }} 7 - <span class="filename text-black dark:text-white">{{ .Name }}</span> 8 - </span> 9 - </summary> 10 - <div class="ml-1 pl-4 border-l border-gray-200 dark:border-gray-700"> 11 - {{ range $child := .Children }} 12 - {{ block "fileTree" $child }} {{ end }} 13 - {{ end }} 14 - </div> 15 - </details> 16 - {{ else if .Name }} 17 - <div class="tree-file flex items-center gap-2 pt-1"> 18 - {{ i "file" "size-4" }} 19 - <a href="#file-{{ .Path }}" class="filename text-black dark:text-white no-underline hover:underline">{{ .Name }}</a> 20 - </div> 21 - {{ else }} 22 - {{ range $child := .Children }} 23 - {{ block "fileTree" $child }} {{ end }} 24 - {{ end }} 25 - {{ end }} 26 - {{ end }} 27 -
+72 -123
appview/pages/templates/repo/fragments/interdiff.html
··· 1 1 {{ define "repo/fragments/interdiff" }} 2 2 {{ $repo := index . 0 }} 3 3 {{ $x := index . 1 }} 4 + {{ $opts := index . 2 }} 4 5 {{ $fileTree := fileTree $x.AffectedFiles }} 5 6 {{ $diff := $x.Files }} 7 + {{ $last := sub (len $diff) 1 }} 8 + {{ $isSplit := $opts.Split }} 6 9 7 - <section class="mt-6 p-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 8 - <div class="diff-stat"> 9 - <div class="flex gap-2 items-center"> 10 - <strong class="text-sm uppercase dark:text-gray-200">files</strong> 11 - </div> 12 - {{ block "fileTree" $fileTree }} {{ end }} 13 - </div> 14 - </section> 15 - 16 - {{ $last := sub (len $diff) 1 }} 10 + <div class="flex flex-col gap-4"> 17 11 {{ range $idx, $hunk := $diff }} 18 - {{ with $hunk }} 19 - <section class="mt-6 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 20 - <div id="file-{{ .Name }}"> 21 - <div id="diff-file"> 22 - <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 23 - <summary class="list-none cursor-pointer sticky top-0"> 24 - <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 25 - <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 26 - <div class="flex gap-1 items-center" style="direction: ltr;"> 27 - {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 28 - {{ if .Status.IsOk }} 29 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 30 - {{ else if .Status.IsUnchanged }} 31 - <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 32 - {{ else if .Status.IsOnlyInOne }} 33 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 34 - {{ else if .Status.IsOnlyInTwo }} 35 - <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 36 - {{ else if .Status.IsRebased }} 37 - <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 38 - {{ else }} 39 - <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 40 - {{ end }} 12 + {{ with $hunk }} 13 + <section class="border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm"> 14 + <div id="file-{{ .Name }}"> 15 + <div id="diff-file"> 16 + <details {{ if not (.Status.IsOnlyInOne) }}open{{end}}> 17 + <summary class="list-none cursor-pointer sticky top-0"> 18 + <div id="diff-file-header" class="rounded cursor-pointer bg-white dark:bg-gray-800 flex justify-between"> 19 + <div id="left-side-items" class="p-2 flex gap-2 items-center overflow-x-auto"> 20 + <div class="flex gap-1 items-center" style="direction: ltr;"> 21 + {{ $markerstyle := "diff-type p-1 mr-1 font-mono text-sm rounded select-none" }} 22 + {{ if .Status.IsOk }} 23 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">CHANGED</span> 24 + {{ else if .Status.IsUnchanged }} 25 + <span class="bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 {{ $markerstyle }}">UNCHANGED</span> 26 + {{ else if .Status.IsOnlyInOne }} 27 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">REVERTED</span> 28 + {{ else if .Status.IsOnlyInTwo }} 29 + <span class="bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400 {{ $markerstyle }}">NEW</span> 30 + {{ else if .Status.IsRebased }} 31 + <span class="bg-amber-100 text-amber-700 dark:bg-amber-800/50 dark:text-amber-400 {{ $markerstyle }}">REBASED</span> 32 + {{ else }} 33 + <span class="bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400 {{ $markerstyle }}">ERROR</span> 34 + {{ end }} 35 + </div> 36 + 37 + <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 38 + <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 39 + {{ .Name }} 40 + </a> 41 + </div> 41 42 </div> 42 43 43 - <div class="flex gap-2 items-center overflow-x-auto" style="direction: rtl;"> 44 - <a class="dark:text-white whitespace-nowrap overflow-x-auto" href=""> 45 - {{ .Name }} 46 - </a> 44 + {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 45 + <div id="right-side-items" class="p-2 flex items-center"> 46 + <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 47 + {{ if gt $idx 0 }} 48 + {{ $prev := index $diff (sub $idx 1) }} 49 + <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 50 + {{ end }} 51 + 52 + {{ if lt $idx $last }} 53 + {{ $next := index $diff (add $idx 1) }} 54 + <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 55 + {{ end }} 47 56 </div> 57 + 48 58 </div> 59 + </summary> 49 60 50 - {{ $iconstyle := "p-1 mx-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" }} 51 - <div id="right-side-items" class="p-2 flex items-center"> 52 - <a title="top of file" href="#file-{{ .Name }}" class="{{ $iconstyle }}">{{ i "arrow-up-to-line" "w-4 h-4" }}</a> 53 - {{ if gt $idx 0 }} 54 - {{ $prev := index $diff (sub $idx 1) }} 55 - <a title="previous file" href="#file-{{ $prev.Name }}" class="{{ $iconstyle }}">{{ i "arrow-up" "w-4 h-4" }}</a> 61 + <div class="transition-all duration-700 ease-in-out"> 62 + {{ if .Status.IsUnchanged }} 63 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 64 + This file has not been changed. 65 + </p> 66 + {{ else if .Status.IsRebased }} 67 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 68 + This patch was likely rebased, as context lines do not match. 69 + </p> 70 + {{ else if .Status.IsError }} 71 + <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 72 + Failed to calculate interdiff for this file. 73 + </p> 74 + {{ else }} 75 + {{ if $isSplit }} 76 + {{- template "repo/fragments/splitDiff" .Split -}} 77 + {{ else }} 78 + {{- template "repo/fragments/unifiedDiff" . -}} 56 79 {{ end }} 57 - 58 - {{ if lt $idx $last }} 59 - {{ $next := index $diff (add $idx 1) }} 60 - <a title="next file" href="#file-{{ $next.Name }}" class="{{ $iconstyle }}">{{ i "arrow-down" "w-4 h-4" }}</a> 61 - {{ end }} 62 - </div> 63 - 80 + {{- end -}} 64 81 </div> 65 - </summary> 66 82 67 - <div class="transition-all duration-700 ease-in-out"> 68 - {{ if .Status.IsUnchanged }} 69 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 70 - This file has not been changed. 71 - </p> 72 - {{ else if .Status.IsRebased }} 73 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 74 - This patch was likely rebased, as context lines do not match. 75 - </p> 76 - {{ else if .Status.IsError }} 77 - <p class="text-center text-gray-400 dark:text-gray-500 p-4"> 78 - Failed to calculate interdiff for this file. 79 - </p> 80 - {{ else }} 81 - {{ $name := .Name }} 82 - <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 83 - {{- $oldStart := .OldPosition -}} 84 - {{- $newStart := .NewPosition -}} 85 - {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 scroll-mt-10 target:border target:border-amber-500 target:rounded " -}} 86 - {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 87 - {{- $lineNrSepStyle1 := "" -}} 88 - {{- $lineNrSepStyle2 := "pr-2" -}} 89 - {{- range .Lines -}} 90 - {{- if eq .Op.String "+" -}} 91 - <div class="bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 flex min-w-full items-center"> 92 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 93 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 94 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 95 - <div class="px-2">{{ .Line }}</div> 96 - </div> 97 - {{- $newStart = add64 $newStart 1 -}} 98 - {{- end -}} 99 - {{- if eq .Op.String "-" -}} 100 - <div class="bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 flex min-w-full items-center"> 101 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 102 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 103 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 104 - <div class="px-2">{{ .Line }}</div> 105 - </div> 106 - {{- $oldStart = add64 $oldStart 1 -}} 107 - {{- end -}} 108 - {{- if eq .Op.String " " -}} 109 - <div class="bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 flex min-w-full items-center"> 110 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}" id="{{$name}}-O{{$oldStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 111 - <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}" id="{{$name}}-N{{$newStart}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 112 - <div class="w-5 flex-shrink-0 select-none text-center">{{ .Op.String }}</div> 113 - <div class="px-2">{{ .Line }}</div> 114 - </div> 115 - {{- $newStart = add64 $newStart 1 -}} 116 - {{- $oldStart = add64 $oldStart 1 -}} 117 - {{- end -}} 118 - {{- end -}} 119 - {{- end -}}</div></div></pre> 120 - {{- end -}} 121 - </div> 83 + </details> 122 84 123 - </details> 124 - 85 + </div> 125 86 </div> 126 - </div> 127 - </section> 128 - {{ end }} 87 + </section> 88 + {{ end }} 129 89 {{ end }} 90 + </div> 130 91 {{ end }} 131 92 132 - {{ define "statPill" }} 133 - <div class="flex items-center font-mono text-sm"> 134 - {{ if and .Insertions .Deletions }} 135 - <span class="rounded-l p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 136 - <span class="rounded-r p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 137 - {{ else if .Insertions }} 138 - <span class="rounded p-1 select-none bg-green-100 text-green-700 dark:bg-green-800/50 dark:text-green-400">+{{ .Insertions }}</span> 139 - {{ else if .Deletions }} 140 - <span class="rounded p-1 select-none bg-red-100 text-red-700 dark:bg-red-800/50 dark:text-red-400">-{{ .Deletions }}</span> 141 - {{ end }} 142 - </div> 143 - {{ end }}
+11
appview/pages/templates/repo/fragments/interdiffFiles.html
··· 1 + {{ define "repo/fragments/interdiffFiles" }} 2 + {{ $fileTree := fileTree .AffectedFiles }} 3 + <section class="mt-4 px-6 py-2 border border-gray-200 dark:border-gray-700 w-full mx-auto rounded bg-white dark:bg-gray-800 drop-shadow-sm md:min-h-screen text-sm"> 4 + <div class="diff-stat"> 5 + <div class="flex gap-2 items-center"> 6 + <strong class="text-sm uppercase dark:text-gray-200">files</strong> 7 + </div> 8 + {{ template "repo/fragments/fileTree" $fileTree }} 9 + </div> 10 + </section> 11 + {{ end }}
+34
appview/pages/templates/repo/fragments/reaction.html
··· 1 + {{ define "repo/fragments/reaction" }} 2 + <button 3 + id="reactIndi-{{ .Kind }}" 4 + class="flex justify-center items-center min-w-8 min-h-8 rounded border 5 + leading-4 px-3 gap-1 6 + {{ if eq .Count 0 }} 7 + hidden 8 + {{ end }} 9 + {{ if .IsReacted }} 10 + bg-sky-100 11 + border-sky-400 12 + dark:bg-sky-900 13 + dark:border-sky-500 14 + {{ else }} 15 + border-gray-200 16 + hover:bg-gray-50 17 + hover:border-gray-300 18 + dark:border-gray-700 19 + dark:hover:bg-gray-700 20 + dark:hover:border-gray-600 21 + {{ end }} 22 + " 23 + {{ if .IsReacted }} 24 + hx-delete="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 25 + {{ else }} 26 + hx-post="/react?subject={{ .ThreadAt }}&kind={{ .Kind }}" 27 + {{ end }} 28 + hx-swap="outerHTML" 29 + hx-trigger="click from:(#reactBtn-{{ .Kind }}, #reactIndi-{{ .Kind }})" 30 + hx-disabled-elt="this" 31 + > 32 + <span>{{ .Kind }}</span> <span>{{ .Count }}</span> 33 + </button> 34 + {{ end }}
+30
appview/pages/templates/repo/fragments/reactionsPopUp.html
··· 1 + {{ define "repo/fragments/reactionsPopUp" }} 2 + <details 3 + id="reactionsPopUp" 4 + class="relative inline-block" 5 + > 6 + <summary 7 + class="flex justify-center items-center min-w-8 min-h-8 rounded border border-gray-200 dark:border-gray-700 8 + hover:bg-gray-50 9 + hover:border-gray-300 10 + dark:hover:bg-gray-700 11 + dark:hover:border-gray-600 12 + cursor-pointer list-none" 13 + > 14 + {{ i "smile" "size-4" }} 15 + </summary> 16 + <div 17 + class="absolute flex left-0 z-10 mt-4 rounded bg-white dark:bg-gray-800 dark:text-white border border-gray-200 dark:border-gray-700 shadow-lg" 18 + > 19 + {{ range $kind := . }} 20 + <button 21 + id="reactBtn-{{ $kind }}" 22 + class="size-12 hover:bg-gray-100 dark:hover:bg-gray-700" 23 + hx-on:click="this.parentElement.parentElement.removeAttribute('open')" 24 + > 25 + {{ $kind }} 26 + </button> 27 + {{ end }} 28 + </div> 29 + </details> 30 + {{ end }}
+61
appview/pages/templates/repo/fragments/splitDiff.html
··· 1 + {{ define "repo/fragments/splitDiff" }} 2 + {{ $name := .Id }} 3 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800" -}} 4 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 5 + {{- $lineNrSepStyle := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 6 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 7 + {{- $emptyStyle := "bg-gray-200/30 dark:bg-gray-700/30" -}} 8 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400" -}} 9 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 10 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 11 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 12 + <div class="grid grid-cols-2 divide-x divide-gray-200 dark:divide-gray-700"> 13 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 14 + {{- range .LeftLines -}} 15 + {{- if .IsEmpty -}} 16 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 17 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 18 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 19 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 20 + </div> 21 + {{- else if eq .Op.String "-" -}} 22 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 23 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 24 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 25 + <div class="px-2">{{ .Content }}</div> 26 + </div> 27 + {{- else if eq .Op.String " " -}} 28 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{.LineNumber}}"> 29 + <div class="{{ $lineNrStyle }} {{ $lineNrSepStyle }}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{.LineNumber}}">{{ .LineNumber }}</a></div> 30 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 31 + <div class="px-2">{{ .Content }}</div> 32 + </div> 33 + {{- end -}} 34 + {{- end -}} 35 + {{- end -}}</div></div></pre> 36 + 37 + <pre class="overflow-x-auto col-span-1"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 38 + {{- range .RightLines -}} 39 + {{- if .IsEmpty -}} 40 + <div class="{{ $emptyStyle }} {{ $containerStyle }}"> 41 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><span aria-hidden="true" class="invisible">{{.LineNumber}}</span></div> 42 + <div class="{{ $opStyle }}"><span aria-hidden="true" class="invisible">{{ .Op.String }}</span></div> 43 + <div class="px-2 invisible" aria-hidden="true">{{ .Content }}</div> 44 + </div> 45 + {{- else if eq .Op.String "+" -}} 46 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 47 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 48 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 49 + <div class="px-2" >{{ .Content }}</div> 50 + </div> 51 + {{- else if eq .Op.String " " -}} 52 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-N{{.LineNumber}}"> 53 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{.LineNumber}}">{{ .LineNumber }}</a></div> 54 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 55 + <div class="px-2">{{ .Content }}</div> 56 + </div> 57 + {{- end -}} 58 + {{- end -}} 59 + {{- end -}}</div></div></pre> 60 + </div> 61 + {{ end }}
+19
appview/pages/templates/repo/fragments/time.html
··· 1 + {{ define "repo/fragments/timeWrapper" }} 2 + <time datetime="{{ .Time | iso8601DateTimeFmt }}" title="{{ .Time | longTimeFmt }}">{{ .Content }}</time> 3 + {{ end }} 4 + 5 + {{ define "repo/fragments/time" }} 6 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | relTimeFmt)) }} 7 + {{ end }} 8 + 9 + {{ define "repo/fragments/shortTime" }} 10 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (. | shortRelTimeFmt)) }} 11 + {{ end }} 12 + 13 + {{ define "repo/fragments/shortTimeAgo" }} 14 + {{ template "repo/fragments/timeWrapper" (dict "Time" . "Content" (print (. | shortRelTimeFmt) " ago")) }} 15 + {{ end }} 16 + 17 + {{ define "repo/fragments/duration" }} 18 + <time datetime="{{ . | iso8601DurationFmt }}" title="{{ . | longDurationFmt }}">{{ . | durationFmt }}</time> 19 + {{ end }}
+47
appview/pages/templates/repo/fragments/unifiedDiff.html
··· 1 + {{ define "repo/fragments/unifiedDiff" }} 2 + {{ $name := .Id }} 3 + <pre class="overflow-x-auto"><div class="overflow-x-auto"><div class="min-w-full inline-block">{{- range .TextFragments -}}<div class="bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 select-none text-center">&middot;&middot;&middot;</div> 4 + {{- $oldStart := .OldPosition -}} 5 + {{- $newStart := .NewPosition -}} 6 + {{- $lineNrStyle := "min-w-[3.5rem] flex-shrink-0 select-none text-right bg-white dark:bg-gray-800 target:bg-yellow-200 target:dark:bg-yellow-600" -}} 7 + {{- $linkStyle := "text-gray-400 dark:text-gray-500 hover:underline" -}} 8 + {{- $lineNrSepStyle1 := "" -}} 9 + {{- $lineNrSepStyle2 := "pr-2 border-r border-gray-200 dark:border-gray-700" -}} 10 + {{- $containerStyle := "flex min-w-full items-center target:border target:rounded-sm target:border-yellow-200 target:dark:border-yellow-700 scroll-mt-20" -}} 11 + {{- $addStyle := "bg-green-100 dark:bg-green-800/30 text-green-700 dark:text-green-400 " -}} 12 + {{- $delStyle := "bg-red-100 dark:bg-red-800/30 text-red-700 dark:text-red-400 " -}} 13 + {{- $ctxStyle := "bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400" -}} 14 + {{- $opStyle := "w-5 flex-shrink-0 select-none text-center" -}} 15 + {{- range .Lines -}} 16 + {{- if eq .Op.String "+" -}} 17 + <div class="{{ $addStyle }} {{ $containerStyle }}" id="{{$name}}-N{{$newStart}}"> 18 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><span aria-hidden="true" class="invisible">{{$newStart}}</span></div> 19 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-N{{$newStart}}">{{ $newStart }}</a></div> 20 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 21 + <div class="px-2">{{ .Line }}</div> 22 + </div> 23 + {{- $newStart = add64 $newStart 1 -}} 24 + {{- end -}} 25 + {{- if eq .Op.String "-" -}} 26 + <div class="{{ $delStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}"> 27 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}">{{ $oldStart }}</a></div> 28 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><span aria-hidden="true" class="invisible">{{$oldStart}}</span></div> 29 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 30 + <div class="px-2">{{ .Line }}</div> 31 + </div> 32 + {{- $oldStart = add64 $oldStart 1 -}} 33 + {{- end -}} 34 + {{- if eq .Op.String " " -}} 35 + <div class="{{ $ctxStyle }} {{ $containerStyle }}" id="{{$name}}-O{{$oldStart}}-N{{$newStart}}"> 36 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle1}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $oldStart }}</a></div> 37 + <div class="{{$lineNrStyle}} {{$lineNrSepStyle2}}"><a class="{{$linkStyle}}" href="#{{$name}}-O{{$oldStart}}-N{{$newStart}}">{{ $newStart }}</a></div> 38 + <div class="{{ $opStyle }}">{{ .Op.String }}</div> 39 + <div class="px-2">{{ .Line }}</div> 40 + </div> 41 + {{- $newStart = add64 $newStart 1 -}} 42 + {{- $oldStart = add64 $oldStart 1 -}} 43 + {{- end -}} 44 + {{- end -}} 45 + {{- end -}}</div></div></pre> 46 + {{ end }} 47 +
+7 -11
appview/pages/templates/repo/index.html
··· 149 149 </a> 150 150 151 151 {{ if .LastCommit }} 152 - <time class="text-xs text-gray-500 dark:text-gray-400" 153 - >{{ timeFmt .LastCommit.When }}</time 154 - > 152 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 155 153 {{ end }} 156 154 </div> 157 155 </div> ··· 172 170 </a> 173 171 174 172 {{ if .LastCommit }} 175 - <time class="text-xs text-gray-500 dark:text-gray-400" 176 - >{{ timeFmt .LastCommit.When }}</time 177 - > 173 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 178 174 {{ end }} 179 175 </div> 180 176 </div> ··· 266 262 {{ end }}" 267 263 class="text-gray-500 dark:text-gray-400 no-underline hover:underline" 268 264 >{{ if $didOrHandle }} 269 - {{ $didOrHandle }} 265 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 270 266 {{ else }} 271 267 {{ .Author.Name }} 272 268 {{ end }}</a 273 269 > 274 270 </span> 275 271 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 276 - <span>{{ timeFmt .Committer.When }}</span> 272 + {{ template "repo/fragments/time" .Committer.When }} 277 273 278 274 <!-- tags/branches --> 279 275 {{ $tagsForCommit := index $.TagMap .Hash.String }} ··· 320 316 </a> 321 317 {{ if .Commit }} 322 318 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 323 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Commit.Committer.When }}</time> 319 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Commit.Committer.When }}</span> 324 320 {{ end }} 325 321 {{ if .IsDefault }} 326 322 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> ··· 366 362 </div> 367 363 <div> 368 364 {{ with .Tag }} 369 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .Tagger.When }}</time> 365 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .Tagger.When }}</span> 370 366 {{ end }} 371 367 {{ if eq $idx 0 }} 372 368 {{ with .Tag }}<span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span>{{ end }} ··· 390 386 dark:[&_pre]:border dark:[&_pre]:border-gray-700 391 387 {{ end }}" 392 388 > 393 - <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-scroll"> 389 + <article class="{{ if .Raw }}whitespace-pre{{ end }}">{{- if .Raw -}}<pre class="dark:bg-gray-800 dark:text-white overflow-x-auto"> 394 390 {{- .HTMLReadme -}} 395 391 </pre> 396 392 {{- else -}}
+1 -1
appview/pages/templates/repo/issues/fragments/editIssueComment.html
··· 19 19 href="#{{ .CommentId }}" 20 20 class="text-gray-500 hover:text-gray-500 hover:underline no-underline" 21 21 id="{{ .CommentId }}"> 22 - {{ .Created | timeFmt }} 22 + {{ template "repo/fragments/time" .Created }} 23 23 </a> 24 24 25 25 <button
+9 -9
appview/pages/templates/repo/issues/fragments/issueComment.html
··· 1 1 {{ define "repo/issues/fragments/issueComment" }} 2 2 {{ with .Comment }} 3 3 <div id="comment-container-{{.CommentId}}"> 4 - <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm"> 4 + <div class="flex items-center gap-2 mb-2 text-gray-500 dark:text-gray-400 text-sm flex-wrap"> 5 5 {{ $owner := index $.DidHandleMap .OwnerDid }} 6 - <a href="/{{ $owner }}" class="no-underline hover:underline">{{ $owner }}</a> 6 + {{ template "user/fragments/picHandleLink" $owner }} 7 7 8 8 <span class="before:content-['ยท']"></span> 9 9 <a ··· 11 11 class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-400 hover:underline no-underline" 12 12 id="{{ .CommentId }}"> 13 13 {{ if .Deleted }} 14 - deleted {{ .Deleted | timeFmt }} 14 + deleted {{ template "repo/fragments/time" .Deleted }} 15 15 {{ else if .Edited }} 16 - edited {{ .Edited | timeFmt }} 16 + edited {{ template "repo/fragments/time" .Edited }} 17 17 {{ else }} 18 - {{ .Created | timeFmt }} 18 + {{ template "repo/fragments/time" .Created }} 19 19 {{ end }} 20 20 </a> 21 - 21 + 22 22 <!-- show user "hats" --> 23 23 {{ $isIssueAuthor := eq .OwnerDid $.Issue.OwnerDid }} 24 24 {{ if $isIssueAuthor }} ··· 29 29 30 30 {{ $isCommentOwner := and $.LoggedInUser (eq $.LoggedInUser.Did .OwnerDid) }} 31 31 {{ if and $isCommentOwner (not .Deleted) }} 32 - <button 33 - class="btn px-2 py-1 text-sm" 32 + <button 33 + class="btn px-2 py-1 text-sm" 34 34 hx-get="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/edit" 35 35 hx-swap="outerHTML" 36 36 hx-target="#comment-container-{{.CommentId}}" 37 37 > 38 38 {{ i "pencil" "w-4 h-4" }} 39 39 </button> 40 - <button 40 + <button 41 41 class="btn px-2 py-1 text-sm text-red-500 flex gap-2 items-center group" 42 42 hx-delete="/{{ $.RepoInfo.FullName }}/issues/{{ .Issue }}/comment/{{ .CommentId }}/" 43 43 hx-confirm="Are you sure you want to delete your comment?"
+17 -5
appview/pages/templates/repo/issues/issue.html
··· 33 33 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 34 34 opened by 35 35 {{ $owner := didOrHandle .Issue.OwnerDid .IssueOwnerHandle }} 36 - {{ template "user/fragments/picHandle" $owner }} 36 + {{ template "user/fragments/picHandleLink" $owner }} 37 37 <span class="select-none before:content-['\00B7']"></span> 38 - <time title="{{ .Issue.Created | longTimeFmt }}"> 39 - {{ .Issue.Created | timeFmt }} 40 - </time> 38 + {{ template "repo/fragments/time" .Issue.Created }} 41 39 </span> 42 40 </div> 43 41 ··· 46 44 {{ .Issue.Body | markdown }} 47 45 </article> 48 46 {{ end }} 47 + 48 + <div class="flex items-center gap-2 mt-2"> 49 + {{ template "repo/fragments/reactionsPopUp" .OrderedReactionKinds }} 50 + {{ range $kind := .OrderedReactionKinds }} 51 + {{ 52 + template "repo/fragments/reaction" 53 + (dict 54 + "Kind" $kind 55 + "Count" (index $.Reactions $kind) 56 + "IsReacted" (index $.UserReacted $kind) 57 + "ThreadAt" $.Issue.IssueAt) 58 + }} 59 + {{ end }} 60 + </div> 49 61 </section> 50 62 {{ end }} 51 63 ··· 76 88 > 77 89 <div class="bg-white dark:bg-gray-800 rounded drop-shadow-sm py-4 px-4 relative w-full md:w-3/5"> 78 90 <div class="text-sm pb-2 text-gray-500 dark:text-gray-400"> 79 - {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} 91 + {{ template "user/fragments/picHandleLink" (didOrHandle .LoggedInUser.Did .LoggedInUser.Handle) }} 80 92 </div> 81 93 <textarea 82 94 id="comment-textarea"
+2 -4
appview/pages/templates/repo/issues/issues.html
··· 66 66 67 67 <span class="ml-1"> 68 68 {{ $owner := index $.DidHandleMap .OwnerDid }} 69 - {{ template "user/fragments/picHandle" $owner }} 69 + {{ template "user/fragments/picHandleLink" $owner }} 70 70 </span> 71 71 72 72 <span class="before:content-['ยท']"> 73 - <time> 74 - {{ .Created | timeFmt }} 75 - </time> 73 + {{ template "repo/fragments/time" .Created }} 76 74 </span> 77 75 78 76 <span class="before:content-['ยท']">
+6 -6
appview/pages/templates/repo/log.html
··· 31 31 <td class=" py-3 align-top"> 32 32 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 33 33 {{ if $didOrHandle }} 34 - <a href="/{{ $didOrHandle }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $didOrHandle }}</a> 34 + {{ template "user/fragments/picHandleLink" $didOrHandle }} 35 35 {{ else }} 36 36 <a href="mailto:{{ $commit.Author.Email }}" class="text-gray-700 dark:text-gray-300 no-underline hover:underline">{{ $commit.Author.Name }}</a> 37 37 {{ end }} ··· 87 87 {{ template "repo/pipelines/fragments/pipelineSymbolLong" (dict "Pipeline" $pipeline "RepoInfo" $.RepoInfo) }} 88 88 {{ end }} 89 89 </td> 90 - <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ timeFmt $commit.Committer.When }}</td> 90 + <td class=" py-3 align-top text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" $commit.Committer.When }}</td> 91 91 </tr> 92 92 {{ end }} 93 93 </tbody> ··· 102 102 <div class="text-base cursor-pointer"> 103 103 <div class="flex items-center justify-between"> 104 104 <div class="flex-1"> 105 - <div class="inline-flex items-end"> 105 + <div> 106 106 <a href="/{{ $.RepoInfo.FullName }}/commit/{{ $commit.Hash.String }}" 107 107 class="inline no-underline hover:underline dark:text-white"> 108 108 {{ index $messageParts 0 }} 109 109 </a> 110 110 {{ if gt (len $messageParts) 1 }} 111 111 <button 112 - class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600 ml-2" 112 + class="py-1/2 px-1 bg-gray-200 hover:bg-gray-400 rounded dark:bg-gray-700 dark:hover:bg-gray-600" 113 113 hx-on:click="this.parentElement.nextElementSibling.classList.toggle('hidden')"> 114 114 {{ i "ellipsis" "w-3 h-3" }} 115 115 </button> ··· 159 159 {{ $didOrHandle := index $.EmailToDidOrHandle $commit.Author.Email }} 160 160 <a href="{{ if $didOrHandle }}/{{ $didOrHandle }}{{ else }}mailto:{{ $commit.Author.Email }}{{ end }}" 161 161 class="text-gray-500 dark:text-gray-400 no-underline hover:underline"> 162 - {{ if $didOrHandle }}{{ $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 162 + {{ if $didOrHandle }}{{ template "user/fragments/picHandleLink" $didOrHandle }}{{ else }}{{ $commit.Author.Name }}{{ end }} 163 163 </a> 164 164 </span> 165 165 <div class="inline-block px-1 select-none after:content-['ยท']"></div> 166 - <span>{{ shortTimeFmt $commit.Committer.When }}</span> 166 + <span>{{ template "repo/fragments/shortTime" $commit.Committer.When }}</span> 167 167 168 168 <!-- ci status --> 169 169 {{ $pipeline := index $.Pipelines .Hash.String }}
+3 -3
appview/pages/templates/repo/pipelines/fragments/logBlock.html
··· 1 1 {{ define "repo/pipelines/fragments/logBlock" }} 2 2 <div id="lines" hx-swap-oob="beforeend"> 3 - <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group bg-gray-100 pb-2 px-2 dark:bg-gray-900"> 4 - <summary class="sticky top-0 pt-2 group-open:pb-2 list-none cursor-pointer bg-gray-100 dark:bg-gray-900 hover:text-gray-500 hover:dark:text-gray-400"> 3 + <details id="step-{{ .Id }}" {{if not .Collapsed}}open{{end}} class="group pb-2 rounded-sm border border-gray-200 dark:border-gray-700"> 4 + <summary class="sticky top-0 pt-2 px-2 group-open:pb-2 group-open:mb-2 list-none cursor-pointer group-open:border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:text-gray-500 hover:dark:text-gray-400"> 5 5 <div class="group-open:hidden flex items-center gap-1"> 6 6 {{ i "chevron-right" "w-4 h-4" }} {{ .Name }} 7 7 </div> ··· 9 9 {{ i "chevron-down" "w-4 h-4" }} {{ .Name }} 10 10 </div> 11 11 </summary> 12 - <div class="font-mono whitespace-pre overflow-x-auto"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 12 + <div class="font-mono whitespace-pre overflow-x-auto px-2"><div class="text-blue-600 dark:text-blue-300">{{ .Command }}</div><div id="step-body-{{ .Id }}"></div></div> 13 13 </details> 14 14 </div> 15 15 {{ end }}
+5 -9
appview/pages/templates/repo/pipelines/fragments/tooltip.html
··· 10 10 {{ $lastStatus := $all.Latest }} 11 11 {{ $kind := $lastStatus.Status.String }} 12 12 13 - {{ $t := .TimeTaken }} 14 - {{ $time := "" }} 15 - {{ if $t }} 16 - {{ $time = durationFmt $t }} 17 - {{ else }} 18 - {{ $time = printf "%s ago" (shortTimeFmt $pipeline.Created) }} 19 - {{ end }} 20 - 21 13 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 22 14 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 23 15 {{ $name }} 24 16 </div> 25 17 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 26 18 <span class="font-bold">{{ $kind }}</span> 27 - <time>{{ $time }}</time> 19 + {{ if .TimeTaken }} 20 + {{ template "repo/fragments/duration" .TimeTaken }} 21 + {{ else }} 22 + {{ template "repo/fragments/shortTimeAgo" $pipeline.Created }} 23 + {{ end }} 28 24 </div> 29 25 </div> 30 26 </a>
+1 -3
appview/pages/templates/repo/pipelines/pipelines.html
··· 74 74 </div> 75 75 76 76 <div class="text-sm md:text-base col-span-1 text-right"> 77 - <time title="{{ .Created | longTimeFmt }}"> 78 - {{ .Created | shortTimeFmt }} ago 79 - </time> 77 + {{ template "repo/fragments/shortTimeAgo" .Created }} 80 78 </div> 81 79 82 80 {{ $t := .TimeTaken }}
+5 -13
appview/pages/templates/repo/pipelines/workflow.html
··· 17 17 </section> 18 18 {{ end }} 19 19 20 - {{ define "repoAfter" }} 21 - {{ end }} 22 - 23 20 {{ define "sidebar" }} 24 21 {{ $active := .Workflow }} 25 22 {{ with .Pipeline }} ··· 32 29 {{ $lastStatus := $all.Latest }} 33 30 {{ $kind := $lastStatus.Status.String }} 34 31 35 - {{ $t := .TimeTaken }} 36 - {{ $time := "" }} 37 - 38 - {{ if $t }} 39 - {{ $time = durationFmt $t }} 40 - {{ else }} 41 - {{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }} 42 - {{ end }} 43 - 44 32 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 45 33 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 46 34 {{ $name }} 47 35 </div> 48 36 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 49 37 <span class="font-bold">{{ $kind }}</span> 50 - <time>{{ $time }}</time> 38 + {{ if .TimeTaken }} 39 + {{ template "repo/fragments/duration" .TimeTaken }} 40 + {{ else }} 41 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 42 + {{ end }} 51 43 </div> 52 44 </div> 53 45 </a>
+18 -2
appview/pages/templates/repo/pulls/fragments/pullHeader.html
··· 29 29 <span class="text-gray-500 dark:text-gray-400 text-sm flex flex-wrap items-center gap-1"> 30 30 opened by 31 31 {{ $owner := index $.DidHandleMap .Pull.OwnerDid }} 32 - {{ template "user/fragments/picHandle" $owner }} 32 + {{ template "user/fragments/picHandleLink" $owner }} 33 33 <span class="select-none before:content-['\00B7']"></span> 34 - <time>{{ .Pull.Created | timeFmt }}</time> 34 + {{ template "repo/fragments/time" .Pull.Created }} 35 35 36 36 <span class="select-none before:content-['\00B7']"></span> 37 37 <span> ··· 60 60 <article id="body" class="mt-8 prose dark:prose-invert"> 61 61 {{ .Pull.Body | markdown }} 62 62 </article> 63 + {{ end }} 64 + 65 + {{ with .OrderedReactionKinds }} 66 + <div class="flex items-center gap-2 mt-2"> 67 + {{ template "repo/fragments/reactionsPopUp" . }} 68 + {{ range $kind := . }} 69 + {{ 70 + template "repo/fragments/reaction" 71 + (dict 72 + "Kind" $kind 73 + "Count" (index $.Reactions $kind) 74 + "IsReacted" (index $.UserReacted $kind) 75 + "ThreadAt" $.Pull.PullAt) 76 + }} 77 + {{ end }} 78 + </div> 63 79 {{ end }} 64 80 </section> 65 81
+2 -3
appview/pages/templates/repo/pulls/fragments/pullNewComment.html
··· 1 1 {{ define "repo/pulls/fragments/pullNewComment" }} 2 - <div 3 - id="pull-comment-card-{{ .RoundNumber }}" 2 + <div 3 + id="pull-comment-card-{{ .RoundNumber }}" 4 4 class="bg-white dark:bg-gray-800 rounded drop-shadow-sm p-4 relative w-full flex flex-col gap-2"> 5 5 <div class="text-sm text-gray-500 dark:text-gray-400"> 6 6 {{ didOrHandle .LoggedInUser.Did .LoggedInUser.Handle }} ··· 38 38 </form> 39 39 </div> 40 40 {{ end }} 41 -
-1
appview/pages/templates/repo/pulls/fragments/pullStack.html
··· 1 1 {{ define "repo/pulls/fragments/pullStack" }} 2 - 3 2 <details class="bg-white dark:bg-gray-800 group" open> 4 3 <summary class="p-2 text-sm font-bold list-none cursor-pointer hover:text-gray-500 hover:dark:text-gray-400"> 5 4 <span class="flex items-center gap-2">
+1 -1
appview/pages/templates/repo/pulls/fragments/summarizedPullHeader.html
··· 17 17 {{ $latestRound := .LastRoundNumber }} 18 18 {{ $lastSubmission := index .Submissions $latestRound }} 19 19 {{ $commentCount := len $lastSubmission.Comments }} 20 - {{ if $pipeline }} 20 + {{ if and $pipeline $pipeline.Id }} 21 21 <div class="inline-flex items-center gap-2"> 22 22 {{ template "repo/pipelines/fragments/pipelineSymbol" $pipeline }} 23 23 <span class="mx-2 before:content-['ยท'] before:select-none"></span>
+36 -3
appview/pages/templates/repo/pulls/interdiff.html
··· 26 26 </header> 27 27 </section> 28 28 29 - <section> 30 - {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff) }} 31 - </section> 29 + {{ end }} 30 + 31 + {{ define "topbarLayout" }} 32 + {{ template "layouts/topbar" . }} 33 + {{ end }} 34 + 35 + {{ define "contentLayout" }} 36 + {{ block "content" . }}{{ end }} 37 + {{ end }} 38 + 39 + {{ define "contentAfterLayout" }} 40 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 41 + <div class="col-span-1 md:col-span-2"> 42 + {{ block "contentAfterLeft" . }} {{ end }} 43 + </div> 44 + <main class="col-span-1 md:col-span-10"> 45 + {{ block "contentAfter" . }}{{ end }} 46 + </main> 47 + </div> 48 + {{ end }} 49 + 50 + {{ define "footerLayout" }} 51 + {{ template "layouts/footer" . }} 32 52 {{ end }} 33 53 54 + 55 + {{ define "contentAfter" }} 56 + {{ template "repo/fragments/interdiff" (list .RepoInfo.FullName .Interdiff .DiffOpts) }} 57 + {{end}} 58 + 59 + {{ define "contentAfterLeft" }} 60 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 61 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 62 + </div> 63 + <div class="sticky top-0 mt-4"> 64 + {{ template "repo/fragments/interdiffFiles" .Interdiff }} 65 + </div> 66 + {{end}}
+36 -1
appview/pages/templates/repo/pulls/patch.html
··· 31 31 <div class="border-t border-gray-200 dark:border-gray-700 my-2"></div> 32 32 {{ template "repo/pulls/fragments/pullHeader" . }} 33 33 </section> 34 - {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff) }} 35 34 </section> 36 35 {{ end }} 36 + 37 + {{ define "topbarLayout" }} 38 + {{ template "layouts/topbar" . }} 39 + {{ end }} 40 + 41 + {{ define "contentLayout" }} 42 + {{ block "content" . }}{{ end }} 43 + {{ end }} 44 + 45 + {{ define "contentAfterLayout" }} 46 + <div class="grid grid-cols-1 md:grid-cols-12 gap-4"> 47 + <div class="col-span-1 md:col-span-2"> 48 + {{ block "contentAfterLeft" . }} {{ end }} 49 + </div> 50 + <main class="col-span-1 md:col-span-10"> 51 + {{ block "contentAfter" . }}{{ end }} 52 + </main> 53 + </div> 54 + {{ end }} 55 + 56 + {{ define "footerLayout" }} 57 + {{ template "layouts/footer" . }} 58 + {{ end }} 59 + 60 + {{ define "contentAfter" }} 61 + {{ template "repo/fragments/diff" (list .RepoInfo.FullName .Diff .DiffOpts) }} 62 + {{end}} 63 + 64 + {{ define "contentAfterLeft" }} 65 + <div class="flex flex-col gap-4 col-span-1 md:col-span-2"> 66 + {{ template "repo/fragments/diffOpts" .DiffOpts }} 67 + </div> 68 + <div class="sticky top-0 mt-4"> 69 + {{ template "repo/fragments/diffChangedFiles" .Diff }} 70 + </div> 71 + {{end}}
+14 -20
appview/pages/templates/repo/pulls/pull.html
··· 5 5 {{ define "extrameta" }} 6 6 {{ $title := printf "%s &middot; pull #%d &middot; %s" .Pull.Title .Pull.PullId .RepoInfo.FullName }} 7 7 {{ $url := printf "https://tangled.sh/%s/pulls/%d" .RepoInfo.FullName .Pull.PullId }} 8 - 8 + 9 9 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 10 10 {{ end }} 11 11 ··· 46 46 </div> 47 47 <!-- round summary --> 48 48 <div class="rounded drop-shadow-sm bg-white dark:bg-gray-800 p-2 text-gray-500 dark:text-gray-400"> 49 - <span> 49 + <span class="gap-1 flex items-center"> 50 50 {{ $owner := index $.DidHandleMap $.Pull.OwnerDid }} 51 51 {{ $re := "re" }} 52 52 {{ if eq .RoundNumber 0 }} 53 53 {{ $re = "" }} 54 54 {{ end }} 55 55 <span class="hidden md:inline">{{$re}}submitted</span> 56 - by <a href="/{{ $owner }}">{{ $owner }}</a> 56 + by {{ template "user/fragments/picHandleLink" $owner }} 57 57 <span class="select-none before:content-['\00B7']"></span> 58 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}"><time>{{ .Created | shortTimeFmt }}</time></a> 58 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500" href="#round-#{{ .RoundNumber }}">{{ template "repo/fragments/shortTime" .Created }}</a> 59 59 <span class="select-none before:content-['ยท']"></span> 60 60 {{ $s := "s" }} 61 61 {{ if eq (len .Comments) 1 }} ··· 68 68 <a class="btn flex items-center gap-2 no-underline hover:no-underline p-2 group" 69 69 hx-boost="true" 70 70 href="/{{ $.RepoInfo.FullName }}/pulls/{{ $.Pull.PullId }}/round/{{.RoundNumber}}"> 71 - {{ i "file-diff" "w-4 h-4" }} 71 + {{ i "file-diff" "w-4 h-4" }} 72 72 <span class="hidden md:inline">diff</span> 73 73 {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 74 74 </a> ··· 150 150 {{ if gt $cidx 0 }} 151 151 <div class="absolute left-8 -top-2 w-px h-2 bg-gray-300 dark:bg-gray-600"></div> 152 152 {{ end }} 153 - <div class="text-sm text-gray-500 dark:text-gray-400"> 154 - {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 - <a href="/{{$owner}}">{{$owner}}</a> 153 + <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 154 + {{ $owner := index $.DidHandleMap $c.OwnerDid }} 155 + {{ template "user/fragments/picHandleLink" $owner }} 156 156 <span class="before:content-['ยท']"></span> 157 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"><time>{{ $c.Created | shortTimeFmt }}</time></a> 157 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}">{{ template "repo/fragments/time" $c.Created }}</a> 158 158 </div> 159 159 <div class="prose dark:prose-invert"> 160 160 {{ $c.Body | markdown }} ··· 179 179 {{ end }} 180 180 </div> 181 181 </details> 182 - <hr class="md:hidden border-t border-gray-300 dark:border-gray-600"/> 183 182 {{ end }} 184 183 {{ end }} 185 184 {{ end }} ··· 277 276 {{ $lastStatus := $all.Latest }} 278 277 {{ $kind := $lastStatus.Status.String }} 279 278 280 - {{ $t := .TimeTaken }} 281 - {{ $time := "" }} 282 - 283 - {{ if $t }} 284 - {{ $time = durationFmt $t }} 285 - {{ else }} 286 - {{ $time = printf "%s ago" (shortTimeFmt $lastStatus.Created) }} 287 - {{ end }} 288 - 289 279 <div id="left" class="flex items-center gap-2 flex-shrink-0"> 290 280 {{ template "repo/pipelines/fragments/workflowSymbol" $all }} 291 281 {{ $name }} 292 282 </div> 293 283 <div id="right" class="flex items-center gap-2 flex-shrink-0"> 294 284 <span class="font-bold">{{ $kind }}</span> 295 - <time>{{ $time }}</time> 285 + {{ if .TimeTaken }} 286 + {{ template "repo/fragments/duration" .TimeTaken }} 287 + {{ else }} 288 + {{ template "repo/fragments/shortTimeAgo" $lastStatus.Created }} 289 + {{ end }} 296 290 </div> 297 291 </div> 298 292 </a>
+3 -5
appview/pages/templates/repo/pulls/pulls.html
··· 76 76 </span> 77 77 78 78 <span class="ml-1"> 79 - {{ template "user/fragments/picHandle" $owner }} 79 + {{ template "user/fragments/picHandleLink" $owner }} 80 80 </span> 81 81 82 - <span> 83 - <time> 84 - {{ .Created | timeFmt }} 85 - </time> 82 + <span class="before:content-['ยท']"> 83 + {{ template "repo/fragments/time" .Created }} 86 84 </span> 87 85 88 86 <span class="before:content-['ยท']">
+2 -2
appview/pages/templates/repo/tags.html
··· 35 35 <span>{{ .Tag.Tagger.Name }}</span> 36 36 37 37 <span class="px-1 text-gray-500 dark:text-gray-400 select-none after:content-['ยท']"></span> 38 - <time>{{ shortTimeFmt .Tag.Tagger.When }}</time> 38 + {{ template "repo/fragments/shortTime" .Tag.Tagger.When }} 39 39 {{ end }} 40 40 </div> 41 41 </div> ··· 54 54 {{ slice .Tag.Target.String 0 8 }} 55 55 </a> 56 56 <span>{{ .Tag.Tagger.Name }}</span> 57 - <time>{{ timeFmt .Tag.Tagger.When }}</time> 57 + {{ template "repo/fragments/time" .Tag.Tagger.When }} 58 58 {{ end }} 59 59 </div> 60 60 </div>
+9 -3
appview/pages/templates/repo/tree.html
··· 11 11 {{ template "repo/fragments/meta" . }} 12 12 {{ $title := printf "%s at %s &middot; %s" $path .Ref .RepoInfo.FullName }} 13 13 {{ $url := printf "https://tangled.sh/%s/tree/%s%s" .RepoInfo.FullName .Ref $path }} 14 - 14 + 15 15 {{ template "repo/fragments/og" (dict "RepoInfo" .RepoInfo "Title" $title "Url" $url) }} 16 16 {{ end }} 17 17 ··· 63 63 </div> 64 64 </a> 65 65 {{ if .LastCommit}} 66 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 66 + <div class="flex items-end gap-2"> 67 + <span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span> 68 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 69 + </div> 67 70 {{ end }} 68 71 </div> 69 72 </div> ··· 80 83 </div> 81 84 </a> 82 85 {{ if .LastCommit}} 83 - <time class="text-xs text-gray-500 dark:text-gray-400">{{ timeFmt .LastCommit.When }}</time> 86 + <div class="flex items-end gap-2"> 87 + <span class="text text-gray-500 dark:text-gray-400 mr-6">{{ .LastCommit.Message }}</span> 88 + <span class="text-xs text-gray-500 dark:text-gray-400">{{ template "repo/fragments/time" .LastCommit.When }}</span> 89 + </div> 84 90 {{ end }} 85 91 </div> 86 92 </div>
+2 -2
appview/pages/templates/settings.html
··· 39 39 {{ i "key" "w-3 h-3 dark:text-gray-300" }} 40 40 <p class="font-bold dark:text-white">{{ .Name }}</p> 41 41 </div> 42 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .Created | timeFmt }}</p> 42 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .Created }}</p> 43 43 <div class="overflow-x-auto whitespace-nowrap flex-1 max-w-full"> 44 44 <code class="text-sm text-gray-500 dark:text-gray-400">{{ .Key }}</code> 45 45 </div> ··· 112 112 {{ end }} 113 113 </div> 114 114 </div> 115 - <p class="text-sm text-gray-500 dark:text-gray-400">added {{ .CreatedAt | timeFmt }}</p> 115 + <p class="text-sm text-gray-500 dark:text-gray-400">added {{ template "repo/fragments/time" .CreatedAt }}</p> 116 116 </div> 117 117 <div class="flex gap-2 items-center"> 118 118 {{ if not .Verified }}
+2 -2
appview/pages/templates/spindles/fragments/spindleListing.html
··· 11 11 {{ i "hard-drive" "w-4 h-4" }} 12 12 {{ .Instance }} 13 13 <span class="text-gray-500"> 14 - {{ .Created | shortTimeFmt }} ago 14 + {{ template "repo/fragments/shortTimeAgo" .Created }} 15 15 </span> 16 16 </a> 17 17 {{ else }} ··· 19 19 {{ i "hard-drive" "w-4 h-4" }} 20 20 {{ .Instance }} 21 21 <span class="text-gray-500"> 22 - {{ .Created | shortTimeFmt }} ago 22 + {{ template "repo/fragments/shortTimeAgo" .Created }} 23 23 </span> 24 24 </div> 25 25 {{ end }}
+14 -2
appview/pages/templates/spindles/index.html
··· 7 7 8 8 <section class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 9 9 <div class="flex flex-col gap-6"> 10 - {{ block "all" . }} {{ end }} 10 + {{ block "about" . }} {{ end }} 11 + {{ block "list" . }} {{ end }} 11 12 {{ block "register" . }} {{ end }} 12 13 </div> 13 14 </section> 14 15 {{ end }} 15 16 16 - {{ define "all" }} 17 + {{ define "about" }} 18 + <section class="rounded flex flex-col gap-2"> 19 + <p class="dark:text-gray-300"> 20 + Spindles are small CI runners. 21 + <a href="https://tangled.sh/@tangled.sh/core/blob/master/docs/spindle/hosting.md"> 22 + Checkout the documentation if you're interested in self-hosting. 23 + </a> 24 + </p> 25 + </section> 26 + {{ end }} 27 + 28 + {{ define "list" }} 17 29 <section class="rounded w-full flex flex-col gap-2"> 18 30 <h2 class="text-sm font-bold py-2 uppercase dark:text-gray-300">your spindles</h2> 19 31 <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 w-full">
+106 -75
appview/pages/templates/timeline.html
··· 49 49 <p class="text-xl font-bold dark:text-white">Timeline</p> 50 50 </div> 51 51 52 - <div class="flex flex-col gap-3 relative"> 53 - <div 54 - class="absolute left-8 top-0 bottom-0 w-px bg-gray-300 dark:bg-gray-600" 55 - ></div> 56 - {{ range .Timeline }} 57 - <div 58 - class="px-6 py-2 bg-white dark:bg-gray-800 rounded drop-shadow-sm w-fit" 59 - > 60 - {{ if .Repo }} 61 - {{ $userHandle := index $.DidHandleMap .Repo.Did }} 62 - <div class="flex items-center"> 63 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 64 - {{ template "user/fragments/picHandle" $userHandle }} 65 - {{ if .Source }} 66 - forked 67 - <a 68 - href="/{{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}" 69 - class="no-underline hover:underline" 70 - > 71 - {{ index $.DidHandleMap .Source.Did }}/{{ .Source.Name }}</a 72 - > 73 - to 74 - <a 75 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 76 - class="no-underline hover:underline" 77 - >{{ .Repo.Name }}</a 78 - > 79 - {{ else }} 80 - created 81 - <a 82 - href="/{{ $userHandle }}/{{ .Repo.Name }}" 83 - class="no-underline hover:underline" 84 - >{{ .Repo.Name }}</a 85 - > 86 - {{ end }} 87 - <time 88 - class="text-gray-700 dark:text-gray-400 text-xs" 89 - >{{ .Repo.Created | timeFmt }}</time 90 - > 91 - </p> 92 - </div> 93 - {{ else if .Follow }} 94 - {{ $userHandle := index $.DidHandleMap .Follow.UserDid }} 95 - {{ $subjectHandle := index $.DidHandleMap .Follow.SubjectDid }} 96 - <div class="flex items-center"> 97 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 98 - {{ template "user/fragments/picHandle" $userHandle }} 99 - followed 100 - {{ template "user/fragments/picHandle" $subjectHandle }} 101 - <time 102 - class="text-gray-700 dark:text-gray-400 text-xs" 103 - >{{ .Follow.FollowedAt | timeFmt }}</time 104 - > 105 - </p> 106 - </div> 107 - {{ else if .Star }} 108 - {{ $starrerHandle := index $.DidHandleMap .Star.StarredByDid }} 109 - {{ $repoOwnerHandle := index $.DidHandleMap .Star.Repo.Did }} 110 - <div class="flex items-center"> 111 - <p class="text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2"> 112 - {{ template "user/fragments/picHandle" $starrerHandle }} 113 - starred 114 - <a 115 - href="/{{ $repoOwnerHandle }}/{{ .Star.Repo.Name }}" 116 - class="no-underline hover:underline" 117 - >{{ $repoOwnerHandle | truncateAt30 }}/{{ .Star.Repo.Name }}</a 118 - > 119 - <time 120 - class="text-gray-700 dark:text-gray-400 text-xs" 121 - >{{ .Star.Created | timeFmt }}</time 122 - > 123 - </p> 124 - </div> 125 - {{ end }} 52 + <div class="flex flex-col gap-4"> 53 + {{ range $i, $e := .Timeline }} 54 + <div class="relative"> 55 + {{ if ne $i 0 }} 56 + <div class="absolute left-8 -top-4 w-px h-4 bg-gray-300 dark:bg-gray-600"></div> 57 + {{ end }} 58 + {{ with $e }} 59 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-sm"> 60 + {{ if .Repo }} 61 + {{ block "repoEvent" (list $ .Repo .Source) }} {{ end }} 62 + {{ else if .Star }} 63 + {{ block "starEvent" (list $ .Star) }} {{ end }} 64 + {{ else if .Follow }} 65 + {{ block "followEvent" (list $ .Follow .Profile .FollowStats) }} {{ end }} 66 + {{ end }} 126 67 </div> 127 - {{ end }} 68 + {{ end }} 69 + </div> 70 + {{ end }} 128 71 </div> 129 72 </div> 130 73 {{ end }} 74 + 75 + {{ define "repoEvent" }} 76 + {{ $root := index . 0 }} 77 + {{ $repo := index . 1 }} 78 + {{ $source := index . 2 }} 79 + {{ $userHandle := index $root.DidHandleMap $repo.Did }} 80 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 81 + {{ template "user/fragments/picHandleLink" $userHandle }} 82 + {{ with $source }} 83 + forked 84 + <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}"class="no-underline hover:underline"> 85 + {{ index $root.DidHandleMap .Did }}/{{ .Name }} 86 + </a> 87 + to 88 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline">{{ $repo.Name }}</a> 89 + {{ else }} 90 + created 91 + <a href="/{{ $userHandle }}/{{ $repo.Name }}" class="no-underline hover:underline"> 92 + {{ $repo.Name }} 93 + </a> 94 + {{ end }} 95 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $repo.Created }}</span> 96 + </div> 97 + {{ with $repo }} 98 + {{ template "user/fragments/repoCard" (list $root . true) }} 99 + {{ end }} 100 + {{ end }} 101 + 102 + {{ define "starEvent" }} 103 + {{ $root := index . 0 }} 104 + {{ $star := index . 1 }} 105 + {{ with $star }} 106 + {{ $starrerHandle := index $root.DidHandleMap .StarredByDid }} 107 + {{ $repoOwnerHandle := index $root.DidHandleMap .Repo.Did }} 108 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 109 + {{ template "user/fragments/picHandleLink" $starrerHandle }} 110 + starred 111 + <a href="/{{ $repoOwnerHandle }}/{{ .Repo.Name }}" class="no-underline hover:underline"> 112 + {{ $repoOwnerHandle | truncateAt30 }}/{{ .Repo.Name }} 113 + </a> 114 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" .Created }}</span> 115 + </div> 116 + {{ with .Repo }} 117 + {{ template "user/fragments/repoCard" (list $root . true) }} 118 + {{ end }} 119 + {{ end }} 120 + {{ end }} 121 + 122 + 123 + {{ define "followEvent" }} 124 + {{ $root := index . 0 }} 125 + {{ $follow := index . 1 }} 126 + {{ $profile := index . 2 }} 127 + {{ $stat := index . 3 }} 128 + 129 + {{ $userHandle := index $root.DidHandleMap $follow.UserDid }} 130 + {{ $subjectHandle := index $root.DidHandleMap $follow.SubjectDid }} 131 + <div class="pl-6 py-2 bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-300 flex flex-wrap items-center gap-2 text-sm"> 132 + {{ template "user/fragments/picHandleLink" $userHandle }} 133 + followed 134 + {{ template "user/fragments/picHandleLink" $subjectHandle }} 135 + <span class="text-gray-700 dark:text-gray-400 text-xs">{{ template "repo/fragments/time" $follow.FollowedAt }}</span> 136 + </div> 137 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800 flex items-center gap-4"> 138 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 139 + <img class="object-cover rounded-full p-2" src="{{ fullAvatar $subjectHandle }}" /> 140 + </div> 141 + 142 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 143 + <a href="/{{ $subjectHandle }}"> 144 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full">{{ $subjectHandle | truncateAt30 }}</span> 145 + </a> 146 + {{ with $profile }} 147 + {{ with .Description }} 148 + <p class="text-sm pb-2 md:pb-2">{{.}}</p> 149 + {{ end }} 150 + {{ end }} 151 + {{ with $stat }} 152 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 153 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 154 + <span id="followers">{{ .Followers }} followers</span> 155 + <span class="select-none after:content-['ยท']"></span> 156 + <span id="following">{{ .Following }} following</span> 157 + </div> 158 + {{ end }} 159 + </div> 160 + </div> 161 + {{ end }}
+6 -8
appview/pages/templates/user/fragments/picHandle.html
··· 1 1 {{ define "user/fragments/picHandle" }} 2 - <a href="/{{ . }}" class="flex items-center"> 3 - <img 4 - src="{{ tinyAvatar . }}" 5 - alt="{{ . }}" 6 - class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 7 - /> 8 - {{ . | truncateAt30 }} 9 - </a> 2 + <img 3 + src="{{ tinyAvatar . }}" 4 + alt="{{ . }}" 5 + class="rounded-full h-6 w-6 mr-1 border border-gray-300 dark:border-gray-700" 6 + /> 7 + {{ . | truncateAt30 }} 10 8 {{ end }}
+5
appview/pages/templates/user/fragments/picHandleLink.html
··· 1 + {{ define "user/fragments/picHandleLink" }} 2 + <a href="/{{ . }}" class="flex items-center"> 3 + {{ template "user/fragments/picHandle" . }} 4 + </a> 5 + {{ end }}
+57
appview/pages/templates/user/fragments/repoCard.html
··· 1 + {{ define "user/fragments/repoCard" }} 2 + {{ $root := index . 0 }} 3 + {{ $repo := index . 1 }} 4 + {{ $fullName := index . 2 }} 5 + 6 + {{ with $repo }} 7 + <div class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 8 + <div class="font-medium dark:text-white flex gap-2 items-center"> 9 + {{- if $fullName -}} 10 + <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ index $root.DidHandleMap .Did }}/{{ .Name }}</a> 11 + {{- else -}} 12 + <a href="/{{ index $root.DidHandleMap .Did }}/{{ .Name }}">{{ .Name }}</a> 13 + {{- end -}} 14 + </div> 15 + {{ with .Description }} 16 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 17 + {{ . }} 18 + </div> 19 + {{ end }} 20 + 21 + {{ if .RepoStats }} 22 + {{ block "repoStats" .RepoStats }} {{ end }} 23 + {{ end }} 24 + </div> 25 + {{ end }} 26 + {{ end }} 27 + 28 + {{ define "repoStats" }} 29 + <div class="text-gray-400 pt-4 text-sm font-mono inline-flex gap-4 mt-auto"> 30 + {{ with .Language }} 31 + <div class="flex gap-2 items-center text-sm"> 32 + <div class="size-2 rounded-full" style="background-color: {{ langColor . }};"></div> 33 + <span>{{ . }}</span> 34 + </div> 35 + {{ end }} 36 + {{ with .StarCount }} 37 + <div class="flex gap-1 items-center text-sm"> 38 + {{ i "star" "w-3 h-3 fill-current" }} 39 + <span>{{ . }}</span> 40 + </div> 41 + {{ end }} 42 + {{ with .IssueCount.Open }} 43 + <div class="flex gap-1 items-center text-sm"> 44 + {{ i "circle-dot" "w-3 h-3" }} 45 + <span>{{ . }}</span> 46 + </div> 47 + {{ end }} 48 + {{ with .PullCount.Open }} 49 + <div class="flex gap-1 items-center text-sm"> 50 + {{ i "git-pull-request" "w-3 h-3" }} 51 + <span>{{ . }}</span> 52 + </div> 53 + {{ end }} 54 + </div> 55 + {{ end }} 56 + 57 +
+7 -49
appview/pages/templates/user/profile.html
··· 8 8 {{ end }} 9 9 10 10 {{ define "content" }} 11 - <div class="grid grid-cols-1 md:grid-cols-8 gap-4"> 12 - <div class="md:col-span-2 order-1 md:order-1"> 11 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 12 + <div class="md:col-span-3 order-1 md:order-1"> 13 13 <div class="grid grid-cols-1 gap-4"> 14 14 {{ template "user/fragments/profileCard" .Card }} 15 15 {{ block "punchcard" .Punchcard }} {{ end }} 16 16 </div> 17 17 </div> 18 - <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 18 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 19 19 <div class="grid grid-cols-1 gap-4"> 20 20 {{ block "ownRepos" . }}{{ end }} 21 21 {{ block "collaboratingRepos" . }}{{ end }} 22 22 </div> 23 23 </div> 24 - <div class="md:col-span-3 order-3 md:order-3"> 24 + <div class="md:col-span-4 order-3 md:order-3"> 25 25 {{ block "profileTimeline" . }}{{ end }} 26 26 </div> 27 27 </div> ··· 258 258 </button> 259 259 {{ end }} 260 260 </div> 261 - <div id="repos" class="grid grid-cols-1 gap-4"> 261 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 262 262 {{ range .Repos }} 263 - <div 264 - id="repo-card" 265 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 266 - <div id="repo-card-name" class="font-medium"> 267 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 268 - >{{ .Name }}</a 269 - > 270 - </div> 271 - {{ if .Description }} 272 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 273 - {{ .Description }} 274 - </div> 275 - {{ end }} 276 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 277 - {{ if .RepoStats.StarCount }} 278 - <div class="flex gap-1 items-center text-sm"> 279 - {{ i "star" "w-3 h-3 fill-current" }} 280 - <span>{{ .RepoStats.StarCount }}</span> 281 - </div> 282 - {{ end }} 283 - </div> 284 - </div> 263 + {{ template "user/fragments/repoCard" (list $ . false) }} 285 264 {{ else }} 286 265 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 287 266 {{ end }} ··· 295 274 <p class="text-sm font-bold p-2 dark:text-white">COLLABORATING ON</p> 296 275 <div id="collaborating" class="grid grid-cols-1 gap-4"> 297 276 {{ range .CollaboratingRepos }} 298 - <div 299 - id="repo-card" 300 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 301 - <div id="repo-card-name" class="font-medium dark:text-white"> 302 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 303 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 304 - </a> 305 - </div> 306 - {{ if .Description }} 307 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 308 - {{ .Description }} 309 - </div> 310 - {{ end }} 311 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 312 - {{ if .RepoStats.StarCount }} 313 - <div class="flex gap-1 items-center text-sm"> 314 - {{ i "star" "w-3 h-3 fill-current" }} 315 - <span>{{ .RepoStats.StarCount }}</span> 316 - </div> 317 - {{ end }} 318 - </div> 319 - </div> 277 + {{ template "user/fragments/repoCard" (list $ . true) }} 320 278 {{ else }} 321 279 <p class="px-6 dark:text-white">This user is not collaborating.</p> 322 280 {{ end }}
+1 -22
appview/pages/templates/user/repos.html
··· 22 22 <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 23 23 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 24 24 {{ range .Repos }} 25 - <div 26 - id="repo-card" 27 - class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 28 - <div id="repo-card-name" class="font-medium"> 29 - <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 30 - >{{ .Name }}</a 31 - > 32 - </div> 33 - {{ if .Description }} 34 - <div class="text-gray-600 dark:text-gray-300 text-sm"> 35 - {{ .Description }} 36 - </div> 37 - {{ end }} 38 - <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 39 - {{ if .RepoStats.StarCount }} 40 - <div class="flex gap-1 items-center text-sm"> 41 - {{ i "star" "w-3 h-3 fill-current" }} 42 - <span>{{ .RepoStats.StarCount }}</span> 43 - </div> 44 - {{ end }} 45 - </div> 46 - </div> 25 + {{ template "user/fragments/repoCard" (list $ . false) }} 47 26 {{ else }} 48 27 <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 49 28 {{ end }}
+27 -1
appview/pulls/pulls.go
··· 198 198 m[p.Sha] = p 199 199 } 200 200 201 + reactionCountMap, err := db.GetReactionCountMap(s.db, pull.PullAt()) 202 + if err != nil { 203 + log.Println("failed to get pull reactions") 204 + s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 205 + } 206 + 207 + userReactions := map[db.ReactionKind]bool{} 208 + if user != nil { 209 + userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 210 + } 211 + 201 212 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 202 213 LoggedInUser: user, 203 214 RepoInfo: repoInfo, ··· 208 219 MergeCheck: mergeCheckResponse, 209 220 ResubmitCheck: resubmitResult, 210 221 Pipelines: m, 222 + 223 + OrderedReactionKinds: db.OrderedReactionKinds, 224 + Reactions: reactionCountMap, 225 + UserReacted: userReactions, 211 226 }) 212 227 } 213 228 ··· 340 355 return 341 356 } 342 357 358 + var diffOpts types.DiffOpts 359 + if d := r.URL.Query().Get("diff"); d == "split" { 360 + diffOpts.Split = true 361 + } 362 + 343 363 pull, ok := r.Context().Value("pull").(*db.Pull) 344 364 if !ok { 345 365 log.Println("failed to get pull") ··· 380 400 Round: roundIdInt, 381 401 Submission: pull.Submissions[roundIdInt], 382 402 Diff: &diff, 403 + DiffOpts: diffOpts, 383 404 }) 384 405 385 406 } ··· 393 414 return 394 415 } 395 416 417 + var diffOpts types.DiffOpts 418 + if d := r.URL.Query().Get("diff"); d == "split" { 419 + diffOpts.Split = true 420 + } 421 + 396 422 pull, ok := r.Context().Value("pull").(*db.Pull) 397 423 if !ok { 398 424 log.Println("failed to get pull") ··· 448 474 Round: roundIdInt, 449 475 DidHandleMap: didHandleMap, 450 476 Interdiff: interdiff, 477 + DiffOpts: diffOpts, 451 478 }) 452 - return 453 479 } 454 480 455 481 func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
+47 -25
appview/repo/index.go
··· 123 123 } 124 124 } 125 125 126 - languageInfo, err := getLanguageInfo(f, signedClient, ref) 126 + // TODO: a bit dirty 127 + languageInfo, err := rp.getLanguageInfo(f, signedClient, chi.URLParam(r, "ref") == "") 127 128 if err != nil { 128 129 log.Printf("failed to compute language percentages: %s", err) 129 130 // non-fatal ··· 153 154 Languages: languageInfo, 154 155 Pipelines: pipelines, 155 156 }) 156 - return 157 157 } 158 158 159 - func getLanguageInfo( 159 + func (rp *Repo) getLanguageInfo( 160 160 f *reporesolver.ResolvedRepo, 161 161 signedClient *knotclient.SignedClient, 162 - ref string, 162 + isDefaultRef bool, 163 163 ) ([]types.RepoLanguageDetails, error) { 164 - repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref) 165 - if err != nil { 166 - return []types.RepoLanguageDetails{}, err 167 - } 168 - if repoLanguages == nil { 169 - repoLanguages = &types.RepoLanguageResponse{Languages: make(map[string]int64)} 170 - } 164 + // first attempt to fetch from db 165 + langs, err := db.GetRepoLanguages( 166 + rp.db, 167 + db.FilterEq("repo_at", f.RepoAt), 168 + db.FilterEq("ref", f.Ref), 169 + ) 171 170 172 - var totalSize int64 173 - for _, fileSize := range repoLanguages.Languages { 174 - totalSize += fileSize 175 - } 176 - 177 - var languageStats []types.RepoLanguageDetails 178 - var otherPercentage float32 = 0 171 + if err != nil || langs == nil { 172 + // non-fatal, fetch langs from ks 173 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 174 + if err != nil { 175 + return nil, err 176 + } 177 + if ls == nil { 178 + return nil, nil 179 + } 179 180 180 - for lang, size := range repoLanguages.Languages { 181 - percentage := (float32(size) / float32(totalSize)) * 100 181 + for l, s := range ls.Languages { 182 + langs = append(langs, db.RepoLanguage{ 183 + RepoAt: f.RepoAt, 184 + Ref: f.Ref, 185 + IsDefaultRef: isDefaultRef, 186 + Language: l, 187 + Bytes: s, 188 + }) 189 + } 182 190 183 - if percentage <= 0.5 { 184 - otherPercentage += percentage 185 - continue 191 + // update appview's cache 192 + err = db.InsertRepoLanguages(rp.db, langs) 193 + if err != nil { 194 + // non-fatal 195 + log.Println("failed to cache lang results", err) 186 196 } 197 + } 187 198 188 - color := enry.GetColor(lang) 199 + var total int64 200 + for _, l := range langs { 201 + total += l.Bytes 202 + } 189 203 190 - languageStats = append(languageStats, types.RepoLanguageDetails{Name: lang, Percentage: percentage, Color: color}) 204 + var languageStats []types.RepoLanguageDetails 205 + for _, l := range langs { 206 + percentage := float32(l.Bytes) / float32(total) * 100 207 + color := enry.GetColor(l.Language) 208 + languageStats = append(languageStats, types.RepoLanguageDetails{ 209 + Name: l.Language, 210 + Percentage: percentage, 211 + Color: color, 212 + }) 191 213 } 192 214 193 215 sort.Slice(languageStats, func(i, j int) bool {
+25 -4
appview/repo/repo.go
··· 106 106 return 107 107 } 108 108 109 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 109 + tagResult, err := us.Tags(f.OwnerDid(), f.RepoName) 110 110 if err != nil { 111 111 log.Println("failed to reach knotserver", err) 112 112 return 113 113 } 114 114 115 115 tagMap := make(map[string][]string) 116 - for _, tag := range result.Tags { 116 + for _, tag := range tagResult.Tags { 117 117 hash := tag.Hash 118 118 if tag.Tag != nil { 119 119 hash = tag.Tag.Target.String() ··· 121 121 tagMap[hash] = append(tagMap[hash], tag.Name) 122 122 } 123 123 124 + branchResult, err := us.Branches(f.OwnerDid(), f.RepoName) 125 + if err != nil { 126 + log.Println("failed to reach knotserver", err) 127 + return 128 + } 129 + 130 + for _, branch := range branchResult.Branches { 131 + hash := branch.Hash 132 + tagMap[hash] = append(tagMap[hash], branch.Name) 133 + } 134 + 124 135 user := rp.oauth.GetUser(r) 125 136 126 137 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true) ··· 154 165 VerifiedCommits: vc, 155 166 Pipelines: pipelines, 156 167 }) 157 - return 158 168 } 159 169 160 170 func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) { ··· 268 278 protocol = "https" 269 279 } 270 280 281 + var diffOpts types.DiffOpts 282 + if d := r.URL.Query().Get("diff"); d == "split" { 283 + diffOpts.Split = true 284 + } 285 + 271 286 if !plumbing.IsHash(ref) { 272 287 rp.pages.Error404(w) 273 288 return ··· 321 336 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap), 322 337 VerifiedCommit: vc, 323 338 Pipeline: pipeline, 339 + DiffOpts: diffOpts, 324 340 }) 325 - return 326 341 } 327 342 328 343 func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) { ··· 1269 1284 return 1270 1285 } 1271 1286 1287 + var diffOpts types.DiffOpts 1288 + if d := r.URL.Query().Get("diff"); d == "split" { 1289 + diffOpts.Split = true 1290 + } 1291 + 1272 1292 // if user is navigating to one of 1273 1293 // /compare/{base}/{head} 1274 1294 // /compare/{base}...{head} ··· 1331 1351 Base: base, 1332 1352 Head: head, 1333 1353 Diff: &diff, 1354 + DiffOpts: diffOpts, 1334 1355 }) 1335 1356 1336 1357 }
+10 -6
appview/spindles/spindles.go
··· 104 104 105 105 repos, err := db.GetRepos( 106 106 s.Db, 107 + 0, 107 108 db.FilterEq("spindle", instance), 108 109 ) 109 110 if err != nil { ··· 316 317 return 317 318 } 318 319 319 - err = s.Enforcer.RemoveSpindle(instance) 320 - if err != nil { 321 - l.Error("failed to update ACL", "err", err) 322 - fail() 323 - return 320 + // delete from enforcer 321 + if spindles[0].Verified != nil { 322 + err = s.Enforcer.RemoveSpindle(instance) 323 + if err != nil { 324 + l.Error("failed to update ACL", "err", err) 325 + fail() 326 + return 327 + } 324 328 } 325 329 326 330 client, err := s.OAuth.AuthorizedClient(r) ··· 579 583 l := s.Logger.With("handler", "removeMember") 580 584 581 585 noticeId := "operation-error" 582 - defaultErr := "Failed to add member. Try again later." 586 + defaultErr := "Failed to remove member. Try again later." 583 587 fail := func() { 584 588 s.Pages.Notice(w, noticeId, defaultErr) 585 589 }
+75 -12
appview/state/knotstream.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "errors" 6 7 "fmt" 7 8 "slices" 8 9 "time" ··· 18 19 "tangled.sh/tangled.sh/core/workflow" 19 20 20 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 + "github.com/go-git/go-git/v5/plumbing" 21 23 "github.com/posthog/posthog-go" 22 24 ) 23 25 ··· 39 41 40 42 cfg := ec.ConsumerConfig{ 41 43 Sources: srcs, 42 - ProcessFunc: knotIngester(ctx, d, enforcer, posthog, c.Core.Dev), 44 + ProcessFunc: knotIngester(d, enforcer, posthog, c.Core.Dev), 43 45 RetryInterval: c.Knotstream.RetryInterval, 44 46 MaxRetryInterval: c.Knotstream.MaxRetryInterval, 45 47 ConnectionTimeout: c.Knotstream.ConnectionTimeout, ··· 53 55 return ec.NewConsumer(cfg), nil 54 56 } 55 57 56 - func knotIngester(ctx context.Context, d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 58 + func knotIngester(d *db.DB, enforcer *rbac.Enforcer, posthog posthog.Client, dev bool) ec.ProcessFunc { 57 59 return func(ctx context.Context, source ec.Source, msg ec.Message) error { 58 60 switch msg.Nsid { 59 61 case tangled.GitRefUpdateNSID: ··· 81 83 return fmt.Errorf("%s does not belong to %s, something is fishy", record.CommitterDid, source.Key()) 82 84 } 83 85 86 + err1 := populatePunchcard(d, record) 87 + err2 := updateRepoLanguages(d, record) 88 + 89 + var err3 error 90 + if !dev { 91 + err3 = pc.Enqueue(posthog.Capture{ 92 + DistinctId: record.CommitterDid, 93 + Event: "git_ref_update", 94 + }) 95 + } 96 + 97 + return errors.Join(err1, err2, err3) 98 + } 99 + 100 + func populatePunchcard(d *db.DB, record tangled.GitRefUpdate) error { 84 101 knownEmails, err := db.GetAllEmails(d, record.CommitterDid) 85 102 if err != nil { 86 103 return err 87 104 } 105 + 88 106 count := 0 89 107 for _, ke := range knownEmails { 90 108 if record.Meta == nil { ··· 108 126 Date: time.Now(), 109 127 Count: count, 110 128 } 111 - if err := db.AddPunch(d, punch); err != nil { 112 - return err 129 + return db.AddPunch(d, punch) 130 + } 131 + 132 + func updateRepoLanguages(d *db.DB, record tangled.GitRefUpdate) error { 133 + if record.Meta == nil || record.Meta.LangBreakdown == nil || record.Meta.LangBreakdown.Inputs == nil { 134 + return fmt.Errorf("empty language data for repo: %s/%s", record.RepoDid, record.RepoName) 135 + } 136 + 137 + repos, err := db.GetRepos( 138 + d, 139 + 0, 140 + db.FilterEq("did", record.RepoDid), 141 + db.FilterEq("name", record.RepoName), 142 + ) 143 + if err != nil { 144 + return fmt.Errorf("failed to look for repo in DB (%s/%s): %w", record.RepoDid, record.RepoName, err) 145 + } 146 + if len(repos) != 1 { 147 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 148 + } 149 + repo := repos[0] 150 + 151 + ref := plumbing.ReferenceName(record.Ref) 152 + if !ref.IsBranch() { 153 + return fmt.Errorf("%s is not a valid reference name", ref) 113 154 } 114 155 115 - if !dev { 116 - err = pc.Enqueue(posthog.Capture{ 117 - DistinctId: record.CommitterDid, 118 - Event: "git_ref_update", 156 + var langs []db.RepoLanguage 157 + for _, l := range record.Meta.LangBreakdown.Inputs { 158 + if l == nil { 159 + continue 160 + } 161 + 162 + langs = append(langs, db.RepoLanguage{ 163 + RepoAt: repo.RepoAt(), 164 + Ref: ref.Short(), 165 + IsDefaultRef: record.Meta.IsDefaultRef, 166 + Language: l.Lang, 167 + Bytes: l.Size, 119 168 }) 120 - if err != nil { 121 - // non-fatal, TODO: log this 122 - } 123 169 } 124 170 125 - return nil 171 + return db.InsertRepoLanguages(d, langs) 126 172 } 127 173 128 174 func ingestPipeline(d *db.DB, source ec.Source, msg ec.Message) error { ··· 138 184 139 185 if record.TriggerMetadata.Repo == nil { 140 186 return fmt.Errorf("empty repo: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 187 + } 188 + 189 + // does this repo have a spindle configured? 190 + repos, err := db.GetRepos( 191 + d, 192 + 0, 193 + db.FilterEq("did", record.TriggerMetadata.Repo.Did), 194 + db.FilterEq("name", record.TriggerMetadata.Repo.Repo), 195 + ) 196 + if err != nil { 197 + return fmt.Errorf("failed to look for repo in DB: nsid %s, rkey %s, %w", msg.Nsid, msg.Rkey, err) 198 + } 199 + if len(repos) != 1 { 200 + return fmt.Errorf("incorrect number of repos returned: %d (expected 1)", len(repos)) 201 + } 202 + if repos[0].Spindle == "" { 203 + return fmt.Errorf("repo does not have a spindle configured yet: nsid %s, rkey %s", msg.Nsid, msg.Rkey) 141 204 } 142 205 143 206 // trigger info
+11 -2
appview/state/profile.go
··· 50 50 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 51 51 } 52 52 53 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 53 + repos, err := db.GetRepos( 54 + s.db, 55 + 0, 56 + db.FilterEq("did", ident.DID.String()), 57 + ) 54 58 if err != nil { 55 59 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 56 60 } ··· 171 175 log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 172 176 } 173 177 174 - repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 178 + repos, err := db.GetRepos( 179 + s.db, 180 + 0, 181 + db.FilterEq("did", ident.DID.String()), 182 + ) 175 183 if err != nil { 176 184 log.Printf("getting repos for %s: %s", ident.DID.String(), err) 177 185 } ··· 192 200 s.pages.ReposPage(w, pages.ReposPageParams{ 193 201 LoggedInUser: loggedInUser, 194 202 Repos: repos, 203 + DidHandleMap: map[string]string{ident.DID.String(): ident.Handle.String()}, 195 204 Card: pages.ProfileCard{ 196 205 UserDid: ident.DID.String(), 197 206 UserHandle: ident.Handle.String(),
+126
appview/state/reaction.go
··· 1 + package state 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + 11 + lexutil "github.com/bluesky-social/indigo/lex/util" 12 + "tangled.sh/tangled.sh/core/api/tangled" 13 + "tangled.sh/tangled.sh/core/appview" 14 + "tangled.sh/tangled.sh/core/appview/db" 15 + "tangled.sh/tangled.sh/core/appview/pages" 16 + ) 17 + 18 + func (s *State) React(w http.ResponseWriter, r *http.Request) { 19 + currentUser := s.oauth.GetUser(r) 20 + 21 + subject := r.URL.Query().Get("subject") 22 + if subject == "" { 23 + log.Println("invalid form") 24 + return 25 + } 26 + 27 + subjectUri, err := syntax.ParseATURI(subject) 28 + if err != nil { 29 + log.Println("invalid form") 30 + return 31 + } 32 + 33 + reactionKind, ok := db.ParseReactionKind(r.URL.Query().Get("kind")) 34 + if !ok { 35 + log.Println("invalid reaction kind") 36 + return 37 + } 38 + 39 + client, err := s.oauth.AuthorizedClient(r) 40 + if err != nil { 41 + log.Println("failed to authorize client", err) 42 + return 43 + } 44 + 45 + switch r.Method { 46 + case http.MethodPost: 47 + createdAt := time.Now().Format(time.RFC3339) 48 + rkey := appview.TID() 49 + resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 50 + Collection: tangled.FeedReactionNSID, 51 + Repo: currentUser.Did, 52 + Rkey: rkey, 53 + Record: &lexutil.LexiconTypeDecoder{ 54 + Val: &tangled.FeedReaction{ 55 + Subject: subjectUri.String(), 56 + Reaction: reactionKind.String(), 57 + CreatedAt: createdAt, 58 + }, 59 + }, 60 + }) 61 + if err != nil { 62 + log.Println("failed to create atproto record", err) 63 + return 64 + } 65 + 66 + err = db.AddReaction(s.db, currentUser.Did, subjectUri, reactionKind, rkey) 67 + if err != nil { 68 + log.Println("failed to react", err) 69 + return 70 + } 71 + 72 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 73 + if err != nil { 74 + log.Println("failed to get reaction count for ", subjectUri) 75 + } 76 + 77 + log.Println("created atproto record: ", resp.Uri) 78 + 79 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 80 + ThreadAt: subjectUri, 81 + Kind: reactionKind, 82 + Count: count, 83 + IsReacted: true, 84 + }) 85 + 86 + return 87 + case http.MethodDelete: 88 + reaction, err := db.GetReaction(s.db, currentUser.Did, subjectUri, reactionKind) 89 + if err != nil { 90 + log.Println("failed to get reaction relationship for", currentUser.Did, subjectUri) 91 + return 92 + } 93 + 94 + _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{ 95 + Collection: tangled.FeedReactionNSID, 96 + Repo: currentUser.Did, 97 + Rkey: reaction.Rkey, 98 + }) 99 + 100 + if err != nil { 101 + log.Println("failed to remove reaction") 102 + return 103 + } 104 + 105 + err = db.DeleteReactionByRkey(s.db, currentUser.Did, reaction.Rkey) 106 + if err != nil { 107 + log.Println("failed to delete reaction from DB") 108 + // this is not an issue, the firehose event might have already done this 109 + } 110 + 111 + count, err := db.GetReactionCount(s.db, subjectUri, reactionKind) 112 + if err != nil { 113 + log.Println("failed to get reaction count for ", subjectUri) 114 + return 115 + } 116 + 117 + s.pages.ThreadReactionFragment(w, pages.ThreadReactionFragmentParams{ 118 + ThreadAt: subjectUri, 119 + Kind: reactionKind, 120 + Count: count, 121 + IsReacted: false, 122 + }) 123 + 124 + return 125 + } 126 + }
+24 -18
appview/state/router.go
··· 7 7 "github.com/go-chi/chi/v5" 8 8 "github.com/gorilla/sessions" 9 9 "tangled.sh/tangled.sh/core/appview/issues" 10 + "tangled.sh/tangled.sh/core/appview/knots" 10 11 "tangled.sh/tangled.sh/core/appview/middleware" 11 12 oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler" 12 13 "tangled.sh/tangled.sh/core/appview/pipelines" ··· 101 102 102 103 r.Get("/", s.Timeline) 103 104 104 - r.Route("/knots", func(r chi.Router) { 105 - r.Use(middleware.AuthMiddleware(s.oauth)) 106 - r.Get("/", s.Knots) 107 - r.Post("/key", s.RegistrationKey) 108 - 109 - r.Route("/{domain}", func(r chi.Router) { 110 - r.Post("/init", s.InitKnotServer) 111 - r.Get("/", s.KnotServerInfo) 112 - r.Route("/member", func(r chi.Router) { 113 - r.Use(mw.KnotOwner()) 114 - r.Get("/", s.ListMembers) 115 - r.Put("/", s.AddMember) 116 - r.Delete("/", s.RemoveMember) 117 - }) 118 - }) 119 - }) 120 - 121 105 r.Route("/repo", func(r chi.Router) { 122 106 r.Route("/new", func(r chi.Router) { 123 107 r.Use(middleware.AuthMiddleware(s.oauth)) ··· 137 121 r.Delete("/", s.Star) 138 122 }) 139 123 124 + r.With(middleware.AuthMiddleware(s.oauth)).Route("/react", func(r chi.Router) { 125 + r.Post("/", s.React) 126 + r.Delete("/", s.React) 127 + }) 128 + 140 129 r.Route("/profile", func(r chi.Router) { 141 130 r.Use(middleware.AuthMiddleware(s.oauth)) 142 131 r.Get("/edit-bio", s.EditBioFragment) ··· 146 135 }) 147 136 148 137 r.Mount("/settings", s.SettingsRouter()) 138 + r.Mount("/knots", s.KnotsRouter(mw)) 149 139 r.Mount("/spindles", s.SpindlesRouter()) 150 140 r.Mount("/", s.OAuthRouter()) 151 141 ··· 190 180 return spindles.Router() 191 181 } 192 182 183 + func (s *State) KnotsRouter(mw *middleware.Middleware) http.Handler { 184 + logger := log.New("knots") 185 + 186 + knots := &knots.Knots{ 187 + Db: s.db, 188 + OAuth: s.oauth, 189 + Pages: s.pages, 190 + Config: s.config, 191 + Enforcer: s.enforcer, 192 + IdResolver: s.idResolver, 193 + Knotstream: s.knotstream, 194 + Logger: logger, 195 + } 196 + 197 + return knots.Router(mw) 198 + } 199 + 193 200 func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler { 194 201 issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog) 195 202 return issues.Router(mw) 196 - 197 203 } 198 204 199 205 func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {
+324 -321
appview/state/state.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/hmac" 6 - "crypto/sha256" 7 - "encoding/hex" 8 5 "fmt" 9 6 "log" 10 7 "log/slog" ··· 202 199 } 203 200 204 201 // requires auth 205 - func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 206 - switch r.Method { 207 - case http.MethodGet: 208 - // list open registrations under this did 209 - 210 - return 211 - case http.MethodPost: 212 - session, err := s.oauth.Stores().Get(r, oauth.SessionName) 213 - if err != nil || session.IsNew { 214 - log.Println("unauthorized attempt to generate registration key") 215 - http.Error(w, "Forbidden", http.StatusUnauthorized) 216 - return 217 - } 218 - 219 - did := session.Values[oauth.SessionDid].(string) 220 - 221 - // check if domain is valid url, and strip extra bits down to just host 222 - domain := r.FormValue("domain") 223 - if domain == "" { 224 - http.Error(w, "Invalid form", http.StatusBadRequest) 225 - return 226 - } 227 - 228 - key, err := db.GenerateRegistrationKey(s.db, domain, did) 229 - 230 - if err != nil { 231 - log.Println(err) 232 - http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 233 - return 234 - } 235 - 236 - w.Write([]byte(key)) 237 - } 238 - } 202 + // func (s *State) RegistrationKey(w http.ResponseWriter, r *http.Request) { 203 + // switch r.Method { 204 + // case http.MethodGet: 205 + // // list open registrations under this did 206 + // 207 + // return 208 + // case http.MethodPost: 209 + // session, err := s.oauth.Stores().Get(r, oauth.SessionName) 210 + // if err != nil || session.IsNew { 211 + // log.Println("unauthorized attempt to generate registration key") 212 + // http.Error(w, "Forbidden", http.StatusUnauthorized) 213 + // return 214 + // } 215 + // 216 + // did := session.Values[oauth.SessionDid].(string) 217 + // 218 + // // check if domain is valid url, and strip extra bits down to just host 219 + // domain := r.FormValue("domain") 220 + // if domain == "" { 221 + // http.Error(w, "Invalid form", http.StatusBadRequest) 222 + // return 223 + // } 224 + // 225 + // key, err := db.GenerateRegistrationKey(s.db, domain, did) 226 + // 227 + // if err != nil { 228 + // log.Println(err) 229 + // http.Error(w, "unable to register this domain", http.StatusNotAcceptable) 230 + // return 231 + // } 232 + // 233 + // w.Write([]byte(key)) 234 + // } 235 + // } 239 236 240 237 func (s *State) Keys(w http.ResponseWriter, r *http.Request) { 241 238 user := chi.URLParam(r, "user") ··· 270 267 } 271 268 272 269 // create a signed request and check if a node responds to that 273 - func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 274 - user := s.oauth.GetUser(r) 270 + // func (s *State) InitKnotServer(w http.ResponseWriter, r *http.Request) { 271 + // user := s.oauth.GetUser(r) 272 + // 273 + // noticeId := "operation-error" 274 + // defaultErr := "Failed to register spindle. Try again later." 275 + // fail := func() { 276 + // s.pages.Notice(w, noticeId, defaultErr) 277 + // } 278 + // 279 + // domain := chi.URLParam(r, "domain") 280 + // if domain == "" { 281 + // http.Error(w, "malformed url", http.StatusBadRequest) 282 + // return 283 + // } 284 + // log.Println("checking ", domain) 285 + // 286 + // secret, err := db.GetRegistrationKey(s.db, domain) 287 + // if err != nil { 288 + // log.Printf("no key found for domain %s: %s\n", domain, err) 289 + // return 290 + // } 291 + // 292 + // client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 293 + // if err != nil { 294 + // log.Println("failed to create client to ", domain) 295 + // } 296 + // 297 + // resp, err := client.Init(user.Did) 298 + // if err != nil { 299 + // w.Write([]byte("no dice")) 300 + // log.Println("domain was unreachable after 5 seconds") 301 + // return 302 + // } 303 + // 304 + // if resp.StatusCode == http.StatusConflict { 305 + // log.Println("status conflict", resp.StatusCode) 306 + // w.Write([]byte("already registered, sorry!")) 307 + // return 308 + // } 309 + // 310 + // if resp.StatusCode != http.StatusNoContent { 311 + // log.Println("status nok", resp.StatusCode) 312 + // w.Write([]byte("no dice")) 313 + // return 314 + // } 315 + // 316 + // // verify response mac 317 + // signature := resp.Header.Get("X-Signature") 318 + // signatureBytes, err := hex.DecodeString(signature) 319 + // if err != nil { 320 + // return 321 + // } 322 + // 323 + // expectedMac := hmac.New(sha256.New, []byte(secret)) 324 + // expectedMac.Write([]byte("ok")) 325 + // 326 + // if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 327 + // log.Printf("response body signature mismatch: %x\n", signatureBytes) 328 + // return 329 + // } 330 + // 331 + // tx, err := s.db.BeginTx(r.Context(), nil) 332 + // if err != nil { 333 + // log.Println("failed to start tx", err) 334 + // http.Error(w, err.Error(), http.StatusInternalServerError) 335 + // return 336 + // } 337 + // defer func() { 338 + // tx.Rollback() 339 + // err = s.enforcer.E.LoadPolicy() 340 + // if err != nil { 341 + // log.Println("failed to rollback policies") 342 + // } 343 + // }() 344 + // 345 + // // mark as registered 346 + // err = db.Register(tx, domain) 347 + // if err != nil { 348 + // log.Println("failed to register domain", err) 349 + // http.Error(w, err.Error(), http.StatusInternalServerError) 350 + // return 351 + // } 352 + // 353 + // // set permissions for this did as owner 354 + // reg, err := db.RegistrationByDomain(tx, domain) 355 + // if err != nil { 356 + // log.Println("failed to register domain", err) 357 + // http.Error(w, err.Error(), http.StatusInternalServerError) 358 + // return 359 + // } 360 + // 361 + // // add basic acls for this domain 362 + // err = s.enforcer.AddKnot(domain) 363 + // if err != nil { 364 + // log.Println("failed to setup owner of domain", err) 365 + // http.Error(w, err.Error(), http.StatusInternalServerError) 366 + // return 367 + // } 368 + // 369 + // // add this did as owner of this domain 370 + // err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 371 + // if err != nil { 372 + // log.Println("failed to setup owner of domain", err) 373 + // http.Error(w, err.Error(), http.StatusInternalServerError) 374 + // return 375 + // } 376 + // 377 + // err = tx.Commit() 378 + // if err != nil { 379 + // log.Println("failed to commit changes", err) 380 + // http.Error(w, err.Error(), http.StatusInternalServerError) 381 + // return 382 + // } 383 + // 384 + // err = s.enforcer.E.SavePolicy() 385 + // if err != nil { 386 + // log.Println("failed to update ACLs", err) 387 + // http.Error(w, err.Error(), http.StatusInternalServerError) 388 + // return 389 + // } 390 + // 391 + // // add this knot to knotstream 392 + // go s.knotstream.AddSource( 393 + // context.Background(), 394 + // eventconsumer.NewKnotSource(domain), 395 + // ) 396 + // 397 + // w.Write([]byte("check success")) 398 + // } 275 399 276 - domain := chi.URLParam(r, "domain") 277 - if domain == "" { 278 - http.Error(w, "malformed url", http.StatusBadRequest) 279 - return 280 - } 281 - log.Println("checking ", domain) 282 - 283 - secret, err := db.GetRegistrationKey(s.db, domain) 284 - if err != nil { 285 - log.Printf("no key found for domain %s: %s\n", domain, err) 286 - return 287 - } 288 - 289 - client, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 290 - if err != nil { 291 - log.Println("failed to create client to ", domain) 292 - } 293 - 294 - resp, err := client.Init(user.Did) 295 - if err != nil { 296 - w.Write([]byte("no dice")) 297 - log.Println("domain was unreachable after 5 seconds") 298 - return 299 - } 300 - 301 - if resp.StatusCode == http.StatusConflict { 302 - log.Println("status conflict", resp.StatusCode) 303 - w.Write([]byte("already registered, sorry!")) 304 - return 305 - } 306 - 307 - if resp.StatusCode != http.StatusNoContent { 308 - log.Println("status nok", resp.StatusCode) 309 - w.Write([]byte("no dice")) 310 - return 311 - } 312 - 313 - // verify response mac 314 - signature := resp.Header.Get("X-Signature") 315 - signatureBytes, err := hex.DecodeString(signature) 316 - if err != nil { 317 - return 318 - } 319 - 320 - expectedMac := hmac.New(sha256.New, []byte(secret)) 321 - expectedMac.Write([]byte("ok")) 322 - 323 - if !hmac.Equal(expectedMac.Sum(nil), signatureBytes) { 324 - log.Printf("response body signature mismatch: %x\n", signatureBytes) 325 - return 326 - } 327 - 328 - tx, err := s.db.BeginTx(r.Context(), nil) 329 - if err != nil { 330 - log.Println("failed to start tx", err) 331 - http.Error(w, err.Error(), http.StatusInternalServerError) 332 - return 333 - } 334 - defer func() { 335 - tx.Rollback() 336 - err = s.enforcer.E.LoadPolicy() 337 - if err != nil { 338 - log.Println("failed to rollback policies") 339 - } 340 - }() 341 - 342 - // mark as registered 343 - err = db.Register(tx, domain) 344 - if err != nil { 345 - log.Println("failed to register domain", err) 346 - http.Error(w, err.Error(), http.StatusInternalServerError) 347 - return 348 - } 349 - 350 - // set permissions for this did as owner 351 - reg, err := db.RegistrationByDomain(tx, domain) 352 - if err != nil { 353 - log.Println("failed to register domain", err) 354 - http.Error(w, err.Error(), http.StatusInternalServerError) 355 - return 356 - } 357 - 358 - // add basic acls for this domain 359 - err = s.enforcer.AddKnot(domain) 360 - if err != nil { 361 - log.Println("failed to setup owner of domain", err) 362 - http.Error(w, err.Error(), http.StatusInternalServerError) 363 - return 364 - } 365 - 366 - // add this did as owner of this domain 367 - err = s.enforcer.AddKnotOwner(domain, reg.ByDid) 368 - if err != nil { 369 - log.Println("failed to setup owner of domain", err) 370 - http.Error(w, err.Error(), http.StatusInternalServerError) 371 - return 372 - } 373 - 374 - err = tx.Commit() 375 - if err != nil { 376 - log.Println("failed to commit changes", err) 377 - http.Error(w, err.Error(), http.StatusInternalServerError) 378 - return 379 - } 380 - 381 - err = s.enforcer.E.SavePolicy() 382 - if err != nil { 383 - log.Println("failed to update ACLs", err) 384 - http.Error(w, err.Error(), http.StatusInternalServerError) 385 - return 386 - } 387 - 388 - // add this knot to knotstream 389 - go s.knotstream.AddSource( 390 - context.Background(), 391 - eventconsumer.NewKnotSource(domain), 392 - ) 393 - 394 - w.Write([]byte("check success")) 395 - } 396 - 397 - func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 398 - domain := chi.URLParam(r, "domain") 399 - if domain == "" { 400 - http.Error(w, "malformed url", http.StatusBadRequest) 401 - return 402 - } 403 - 404 - user := s.oauth.GetUser(r) 405 - reg, err := db.RegistrationByDomain(s.db, domain) 406 - if err != nil { 407 - w.Write([]byte("failed to pull up registration info")) 408 - return 409 - } 410 - 411 - var members []string 412 - if reg.Registered != nil { 413 - members, err = s.enforcer.GetUserByRole("server:member", domain) 414 - if err != nil { 415 - w.Write([]byte("failed to fetch member list")) 416 - return 417 - } 418 - } 419 - 420 - var didsToResolve []string 421 - for _, m := range members { 422 - didsToResolve = append(didsToResolve, m) 423 - } 424 - didsToResolve = append(didsToResolve, reg.ByDid) 425 - resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 426 - didHandleMap := make(map[string]string) 427 - for _, identity := range resolvedIds { 428 - if !identity.Handle.IsInvalidHandle() { 429 - didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 430 - } else { 431 - didHandleMap[identity.DID.String()] = identity.DID.String() 432 - } 433 - } 434 - 435 - ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 436 - isOwner := err == nil && ok 437 - 438 - p := pages.KnotParams{ 439 - LoggedInUser: user, 440 - DidHandleMap: didHandleMap, 441 - Registration: reg, 442 - Members: members, 443 - IsOwner: isOwner, 444 - } 445 - 446 - s.pages.Knot(w, p) 447 - } 400 + // func (s *State) KnotServerInfo(w http.ResponseWriter, r *http.Request) { 401 + // domain := chi.URLParam(r, "domain") 402 + // if domain == "" { 403 + // http.Error(w, "malformed url", http.StatusBadRequest) 404 + // return 405 + // } 406 + // 407 + // user := s.oauth.GetUser(r) 408 + // reg, err := db.RegistrationByDomain(s.db, domain) 409 + // if err != nil { 410 + // w.Write([]byte("failed to pull up registration info")) 411 + // return 412 + // } 413 + // 414 + // var members []string 415 + // if reg.Registered != nil { 416 + // members, err = s.enforcer.GetUserByRole("server:member", domain) 417 + // if err != nil { 418 + // w.Write([]byte("failed to fetch member list")) 419 + // return 420 + // } 421 + // } 422 + // 423 + // var didsToResolve []string 424 + // for _, m := range members { 425 + // didsToResolve = append(didsToResolve, m) 426 + // } 427 + // didsToResolve = append(didsToResolve, reg.ByDid) 428 + // resolvedIds := s.idResolver.ResolveIdents(r.Context(), didsToResolve) 429 + // didHandleMap := make(map[string]string) 430 + // for _, identity := range resolvedIds { 431 + // if !identity.Handle.IsInvalidHandle() { 432 + // didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 433 + // } else { 434 + // didHandleMap[identity.DID.String()] = identity.DID.String() 435 + // } 436 + // } 437 + // 438 + // ok, err := s.enforcer.IsKnotOwner(user.Did, domain) 439 + // isOwner := err == nil && ok 440 + // 441 + // p := pages.KnotParams{ 442 + // LoggedInUser: user, 443 + // DidHandleMap: didHandleMap, 444 + // Registration: reg, 445 + // Members: members, 446 + // IsOwner: isOwner, 447 + // } 448 + // 449 + // s.pages.Knot(w, p) 450 + // } 448 451 449 452 // get knots registered by this user 450 - func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 451 - // for now, this is just pubkeys 452 - user := s.oauth.GetUser(r) 453 - registrations, err := db.RegistrationsByDid(s.db, user.Did) 454 - if err != nil { 455 - log.Println(err) 456 - } 457 - 458 - s.pages.Knots(w, pages.KnotsParams{ 459 - LoggedInUser: user, 460 - Registrations: registrations, 461 - }) 462 - } 453 + // func (s *State) Knots(w http.ResponseWriter, r *http.Request) { 454 + // // for now, this is just pubkeys 455 + // user := s.oauth.GetUser(r) 456 + // registrations, err := db.RegistrationsByDid(s.db, user.Did) 457 + // if err != nil { 458 + // log.Println(err) 459 + // } 460 + // 461 + // s.pages.Knots(w, pages.KnotsParams{ 462 + // LoggedInUser: user, 463 + // Registrations: registrations, 464 + // }) 465 + // } 463 466 464 467 // list members of domain, requires auth and requires owner status 465 - func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 466 - domain := chi.URLParam(r, "domain") 467 - if domain == "" { 468 - http.Error(w, "malformed url", http.StatusBadRequest) 469 - return 470 - } 471 - 472 - // list all members for this domain 473 - memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 474 - if err != nil { 475 - w.Write([]byte("failed to fetch member list")) 476 - return 477 - } 478 - 479 - w.Write([]byte(strings.Join(memberDids, "\n"))) 480 - return 481 - } 468 + // func (s *State) ListMembers(w http.ResponseWriter, r *http.Request) { 469 + // domain := chi.URLParam(r, "domain") 470 + // if domain == "" { 471 + // http.Error(w, "malformed url", http.StatusBadRequest) 472 + // return 473 + // } 474 + // 475 + // // list all members for this domain 476 + // memberDids, err := s.enforcer.GetUserByRole("server:member", domain) 477 + // if err != nil { 478 + // w.Write([]byte("failed to fetch member list")) 479 + // return 480 + // } 481 + // 482 + // w.Write([]byte(strings.Join(memberDids, "\n"))) 483 + // return 484 + // } 482 485 483 486 // add member to domain, requires auth and requires invite access 484 - func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 485 - domain := chi.URLParam(r, "domain") 486 - if domain == "" { 487 - http.Error(w, "malformed url", http.StatusBadRequest) 488 - return 489 - } 487 + // func (s *State) AddMember(w http.ResponseWriter, r *http.Request) { 488 + // domain := chi.URLParam(r, "domain") 489 + // if domain == "" { 490 + // http.Error(w, "malformed url", http.StatusBadRequest) 491 + // return 492 + // } 493 + // 494 + // subjectIdentifier := r.FormValue("subject") 495 + // if subjectIdentifier == "" { 496 + // http.Error(w, "malformed form", http.StatusBadRequest) 497 + // return 498 + // } 499 + // 500 + // subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 501 + // if err != nil { 502 + // w.Write([]byte("failed to resolve member did to a handle")) 503 + // return 504 + // } 505 + // log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 506 + // 507 + // // announce this relation into the firehose, store into owners' pds 508 + // client, err := s.oauth.AuthorizedClient(r) 509 + // if err != nil { 510 + // http.Error(w, "failed to authorize client", http.StatusInternalServerError) 511 + // return 512 + // } 513 + // currentUser := s.oauth.GetUser(r) 514 + // createdAt := time.Now().Format(time.RFC3339) 515 + // resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 516 + // Collection: tangled.KnotMemberNSID, 517 + // Repo: currentUser.Did, 518 + // Rkey: appview.TID(), 519 + // Record: &lexutil.LexiconTypeDecoder{ 520 + // Val: &tangled.KnotMember{ 521 + // Subject: subjectIdentity.DID.String(), 522 + // Domain: domain, 523 + // CreatedAt: createdAt, 524 + // }}, 525 + // }) 526 + // 527 + // // invalid record 528 + // if err != nil { 529 + // log.Printf("failed to create record: %s", err) 530 + // return 531 + // } 532 + // log.Println("created atproto record: ", resp.Uri) 533 + // 534 + // secret, err := db.GetRegistrationKey(s.db, domain) 535 + // if err != nil { 536 + // log.Printf("no key found for domain %s: %s\n", domain, err) 537 + // return 538 + // } 539 + // 540 + // ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 541 + // if err != nil { 542 + // log.Println("failed to create client to ", domain) 543 + // return 544 + // } 545 + // 546 + // ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 547 + // if err != nil { 548 + // log.Printf("failed to make request to %s: %s", domain, err) 549 + // return 550 + // } 551 + // 552 + // if ksResp.StatusCode != http.StatusNoContent { 553 + // w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 554 + // return 555 + // } 556 + // 557 + // err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 558 + // if err != nil { 559 + // w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 560 + // return 561 + // } 562 + // 563 + // w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 564 + // } 490 565 491 - subjectIdentifier := r.FormValue("subject") 492 - if subjectIdentifier == "" { 493 - http.Error(w, "malformed form", http.StatusBadRequest) 494 - return 495 - } 496 - 497 - subjectIdentity, err := s.idResolver.ResolveIdent(r.Context(), subjectIdentifier) 498 - if err != nil { 499 - w.Write([]byte("failed to resolve member did to a handle")) 500 - return 501 - } 502 - log.Printf("adding %s to %s\n", subjectIdentity.Handle.String(), domain) 503 - 504 - // announce this relation into the firehose, store into owners' pds 505 - client, err := s.oauth.AuthorizedClient(r) 506 - if err != nil { 507 - http.Error(w, "failed to authorize client", http.StatusInternalServerError) 508 - return 509 - } 510 - currentUser := s.oauth.GetUser(r) 511 - createdAt := time.Now().Format(time.RFC3339) 512 - resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 513 - Collection: tangled.KnotMemberNSID, 514 - Repo: currentUser.Did, 515 - Rkey: appview.TID(), 516 - Record: &lexutil.LexiconTypeDecoder{ 517 - Val: &tangled.KnotMember{ 518 - Subject: subjectIdentity.DID.String(), 519 - Domain: domain, 520 - CreatedAt: createdAt, 521 - }}, 522 - }) 523 - 524 - // invalid record 525 - if err != nil { 526 - log.Printf("failed to create record: %s", err) 527 - return 528 - } 529 - log.Println("created atproto record: ", resp.Uri) 530 - 531 - secret, err := db.GetRegistrationKey(s.db, domain) 532 - if err != nil { 533 - log.Printf("no key found for domain %s: %s\n", domain, err) 534 - return 535 - } 536 - 537 - ksClient, err := knotclient.NewSignedClient(domain, secret, s.config.Core.Dev) 538 - if err != nil { 539 - log.Println("failed to create client to ", domain) 540 - return 541 - } 542 - 543 - ksResp, err := ksClient.AddMember(subjectIdentity.DID.String()) 544 - if err != nil { 545 - log.Printf("failed to make request to %s: %s", domain, err) 546 - return 547 - } 548 - 549 - if ksResp.StatusCode != http.StatusNoContent { 550 - w.Write([]byte(fmt.Sprint("knotserver failed to add member: ", err))) 551 - return 552 - } 553 - 554 - err = s.enforcer.AddKnotMember(domain, subjectIdentity.DID.String()) 555 - if err != nil { 556 - w.Write([]byte(fmt.Sprint("failed to add member: ", err))) 557 - return 558 - } 559 - 560 - w.Write([]byte(fmt.Sprint("added member: ", subjectIdentity.Handle.String()))) 561 - } 562 - 563 - func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 564 - } 566 + // func (s *State) RemoveMember(w http.ResponseWriter, r *http.Request) { 567 + // } 565 568 566 569 func validateRepoName(name string) error { 567 570 // check for path traversal attempts
+8 -6
appview/state/userutil/userutil.go
··· 5 5 "strings" 6 6 ) 7 7 8 + var ( 9 + handleRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 10 + didRegex = regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 11 + ) 12 + 8 13 func IsHandleNoAt(s string) bool { 9 14 // ref: https://atproto.com/specs/handle 10 - re := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) 11 - return re.MatchString(s) 15 + return handleRegex.MatchString(s) 12 16 } 13 17 14 18 func UnflattenDid(s string) string { ··· 29 33 // Reconstruct as a standard DID format using Replace 30 34 // Example: "did-plc-xyz-abc" becomes "did:plc:xyz-abc" 31 35 reconstructed := strings.Replace(s, "-", ":", 2) 32 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 33 36 34 - return re.MatchString(reconstructed) 37 + return didRegex.MatchString(reconstructed) 35 38 } 36 39 37 40 // FlattenDid converts a DID to a flattened format. ··· 46 49 47 50 // IsDid checks if the given string is a standard DID. 48 51 func IsDid(s string) bool { 49 - re := regexp.MustCompile(`^did:[a-z]+:[a-zA-Z0-9._:%-]*[a-zA-Z0-9._-]$`) 50 - return re.MatchString(s) 52 + return didRegex.MatchString(s) 51 53 }
+3
cmd/gen.go
··· 15 15 "api/tangled/cbor_gen.go", 16 16 "tangled", 17 17 tangled.ActorProfile{}, 18 + tangled.FeedReaction{}, 18 19 tangled.FeedStar{}, 19 20 tangled.GitRefUpdate{}, 20 21 tangled.GitRefUpdate_Meta{}, 21 22 tangled.GitRefUpdate_Meta_CommitCount{}, 22 23 tangled.GitRefUpdate_Meta_CommitCount_ByEmail_Elem{}, 24 + tangled.GitRefUpdate_Meta_LangBreakdown{}, 25 + tangled.GitRefUpdate_Pair{}, 23 26 tangled.GraphFollow{}, 24 27 tangled.KnotMember{}, 25 28 tangled.Pipeline{},
+8 -1
docs/spindle/hosting.md
··· 36 36 go build -o cmd/spindle/spindle cmd/spindle/main.go 37 37 ``` 38 38 39 - 3. **Run the Spindle binary.** 39 + 3. **Create the log directory.** 40 + 41 + ```shell 42 + sudo mkdir -p /var/log/spindle 43 + sudo chown $USER:$USER -R /var/log/spindle 44 + ``` 45 + 46 + 4. **Run the Spindle binary.** 40 47 41 48 ```shell 42 49 ./cmd/spindle/spindle
+58 -3
flake.lock
··· 20 20 "type": "github" 21 21 } 22 22 }, 23 + "flake-utils": { 24 + "inputs": { 25 + "systems": "systems" 26 + }, 27 + "locked": { 28 + "lastModified": 1694529238, 29 + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", 30 + "owner": "numtide", 31 + "repo": "flake-utils", 32 + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", 33 + "type": "github" 34 + }, 35 + "original": { 36 + "owner": "numtide", 37 + "repo": "flake-utils", 38 + "type": "github" 39 + } 40 + }, 41 + "gomod2nix": { 42 + "inputs": { 43 + "flake-utils": "flake-utils", 44 + "nixpkgs": [ 45 + "nixpkgs" 46 + ] 47 + }, 48 + "locked": { 49 + "lastModified": 1751702058, 50 + "narHash": "sha256-/GTdqFzFw/Y9DSNAfzvzyCMlKjUyRKMPO+apIuaTU4A=", 51 + "owner": "nix-community", 52 + "repo": "gomod2nix", 53 + "rev": "664ad7a2df4623037e315e4094346bff5c44e9ee", 54 + "type": "github" 55 + }, 56 + "original": { 57 + "owner": "nix-community", 58 + "repo": "gomod2nix", 59 + "type": "github" 60 + } 61 + }, 23 62 "htmx-src": { 24 63 "flake": false, 25 64 "locked": { ··· 101 140 }, 102 141 "nixpkgs": { 103 142 "locked": { 104 - "lastModified": 1746904237, 105 - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", 143 + "lastModified": 1751984180, 144 + "narHash": "sha256-LwWRsENAZJKUdD3SpLluwDmdXY9F45ZEgCb0X+xgOL0=", 106 145 "owner": "nixos", 107 146 "repo": "nixpkgs", 108 - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", 147 + "rev": "9807714d6944a957c2e036f84b0ff8caf9930bc0", 109 148 "type": "github" 110 149 }, 111 150 "original": { ··· 118 157 "root": { 119 158 "inputs": { 120 159 "gitignore": "gitignore", 160 + "gomod2nix": "gomod2nix", 121 161 "htmx-src": "htmx-src", 122 162 "htmx-ws-src": "htmx-ws-src", 123 163 "ibm-plex-mono-src": "ibm-plex-mono-src", ··· 139 179 "original": { 140 180 "type": "tarball", 141 181 "url": "https://sqlite.org/2024/sqlite-amalgamation-3450100.zip" 182 + } 183 + }, 184 + "systems": { 185 + "locked": { 186 + "lastModified": 1681028828, 187 + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 188 + "owner": "nix-systems", 189 + "repo": "default", 190 + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 191 + "type": "github" 192 + }, 193 + "original": { 194 + "owner": "nix-systems", 195 + "repo": "default", 196 + "type": "github" 142 197 } 143 198 } 144 199 },
+38 -20
flake.nix
··· 3 3 4 4 inputs = { 5 5 nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 6 + gomod2nix = { 7 + url = "github:nix-community/gomod2nix"; 8 + inputs.nixpkgs.follows = "nixpkgs"; 9 + }; 6 10 indigo = { 7 11 url = "github:oppiliappan/indigo"; 8 12 flake = false; ··· 42 46 outputs = { 43 47 self, 44 48 nixpkgs, 49 + gomod2nix, 45 50 indigo, 46 51 htmx-src, 47 52 htmx-ws-src, ··· 54 59 supportedSystems = ["x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin"]; 55 60 forAllSystems = nixpkgs.lib.genAttrs supportedSystems; 56 61 nixpkgsFor = forAllSystems (system: nixpkgs.legacyPackages.${system}); 57 - inherit (gitignore.lib) gitignoreSource; 58 - mkPackageSet = pkgs: let 59 - goModHash = "sha256-SLi+nALwCd/Lzn3aljwPqCo2UaM9hl/4OAjcHQLt2Bk="; 60 - sqlite-lib = pkgs.callPackage ./nix/pkgs/sqlite-lib.nix { 61 - inherit (pkgs) gcc; 62 - inherit sqlite-lib-src; 63 - }; 64 - genjwks = pkgs.callPackage ./nix/pkgs/genjwks.nix {inherit goModHash gitignoreSource;}; 65 - lexgen = pkgs.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 66 - appview = pkgs.callPackage ./nix/pkgs/appview.nix { 67 - inherit sqlite-lib htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src goModHash gitignoreSource; 68 - }; 69 - spindle = pkgs.callPackage ./nix/pkgs/spindle.nix {inherit sqlite-lib goModHash gitignoreSource;}; 70 - knot-unwrapped = pkgs.callPackage ./nix/pkgs/knot-unwrapped.nix {inherit sqlite-lib goModHash gitignoreSource;}; 71 - knot = pkgs.callPackage ./nix/pkgs/knot.nix {inherit knot-unwrapped;}; 72 - in { 73 - inherit lexgen appview spindle knot-unwrapped knot sqlite-lib genjwks; 74 - }; 62 + 63 + mkPackageSet = pkgs: 64 + pkgs.lib.makeScope pkgs.newScope (self: { 65 + inherit (gitignore.lib) gitignoreSource; 66 + buildGoApplication = 67 + (self.callPackage "${gomod2nix}/builder" { 68 + gomod2nix = gomod2nix.legacyPackages.${pkgs.system}.gomod2nix; 69 + }).buildGoApplication; 70 + modules = ./nix/gomod2nix.toml; 71 + sqlite-lib = self.callPackage ./nix/pkgs/sqlite-lib.nix { 72 + inherit (pkgs) gcc; 73 + inherit sqlite-lib-src; 74 + }; 75 + genjwks = self.callPackage ./nix/pkgs/genjwks.nix {}; 76 + lexgen = self.callPackage ./nix/pkgs/lexgen.nix {inherit indigo;}; 77 + appview = self.callPackage ./nix/pkgs/appview.nix { 78 + inherit htmx-src htmx-ws-src lucide-src inter-fonts-src ibm-plex-mono-src; 79 + }; 80 + spindle = self.callPackage ./nix/pkgs/spindle.nix {}; 81 + knot-unwrapped = self.callPackage ./nix/pkgs/knot-unwrapped.nix {}; 82 + knot = self.callPackage ./nix/pkgs/knot.nix {}; 83 + }); 75 84 in { 76 - overlays.default = final: prev: mkPackageSet final; 85 + overlays.default = final: prev: { 86 + inherit (mkPackageSet final) lexgen sqlite-lib genjwks spindle knot-unwrapped knot appview; 87 + }; 77 88 78 89 packages = forAllSystems (system: let 79 90 pkgs = nixpkgsFor.${system}; ··· 142 153 '' 143 154 ${pkgs.air}/bin/air -c /dev/null \ 144 155 -build.cmd "${pkgs.go}/bin/go build -o ./out/${name}.out ./cmd/${name}/main.go" \ 145 - -build.bin "./out/${name}.out ${arg}" \ 156 + -build.bin "./out/${name}.out" \ 157 + -build.args_bin "${arg}" \ 146 158 -build.stop_on_error "true" \ 147 159 -build.include_ext "go" 148 160 ''; ··· 168 180 type = "app"; 169 181 program = toString (pkgs.writeShellScript "vm" '' 170 182 ${pkgs.nixos-shell}/bin/nixos-shell --flake .#vm 183 + ''); 184 + }; 185 + gomod2nix = { 186 + type = "app"; 187 + program = toString (pkgs.writeShellScript "gomod2nix" '' 188 + ${gomod2nix.legacyPackages.${system}.gomod2nix}/bin/gomod2nix generate --outdir ./nix 171 189 ''); 172 190 }; 173 191 });
+112
knotserver/git/branch.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + "tangled.sh/tangled.sh/core/types" 13 + ) 14 + 15 + func (g *GitRepo) Branches() ([]types.Branch, error) { 16 + fields := []string{ 17 + "refname:short", 18 + "objectname", 19 + "authorname", 20 + "authoremail", 21 + "authordate:unix", 22 + "committername", 23 + "committeremail", 24 + "committerdate:unix", 25 + "tree", 26 + "parent", 27 + "contents", 28 + } 29 + 30 + var outFormat strings.Builder 31 + outFormat.WriteString("--format=") 32 + for i, f := range fields { 33 + if i != 0 { 34 + outFormat.WriteString(fieldSeparator) 35 + } 36 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 37 + } 38 + outFormat.WriteString("") 39 + outFormat.WriteString(recordSeparator) 40 + 41 + output, err := g.forEachRef(outFormat.String(), "refs/heads") 42 + if err != nil { 43 + return nil, fmt.Errorf("failed to get branches: %w", err) 44 + } 45 + 46 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 47 + if len(records) == 1 && records[0] == "" { 48 + return nil, nil 49 + } 50 + 51 + branches := make([]types.Branch, 0, len(records)) 52 + 53 + // ignore errors here 54 + defaultBranch, _ := g.FindMainBranch() 55 + 56 + for _, line := range records { 57 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 58 + if len(parts) < 6 { 59 + continue 60 + } 61 + 62 + branchName := parts[0] 63 + commitHash := plumbing.NewHash(parts[1]) 64 + authorName := parts[2] 65 + authorEmail := strings.TrimSuffix(strings.TrimPrefix(parts[3], "<"), ">") 66 + authorDate := parts[4] 67 + committerName := parts[5] 68 + committerEmail := strings.TrimSuffix(strings.TrimPrefix(parts[6], "<"), ">") 69 + committerDate := parts[7] 70 + treeHash := plumbing.NewHash(parts[8]) 71 + parentHash := plumbing.NewHash(parts[9]) 72 + message := parts[10] 73 + 74 + // parse creation time 75 + var authoredAt, committedAt time.Time 76 + if unix, err := strconv.ParseInt(authorDate, 10, 64); err == nil { 77 + authoredAt = time.Unix(unix, 0) 78 + } 79 + if unix, err := strconv.ParseInt(committerDate, 10, 64); err == nil { 80 + committedAt = time.Unix(unix, 0) 81 + } 82 + 83 + branch := types.Branch{ 84 + IsDefault: branchName == defaultBranch, 85 + Reference: types.Reference{ 86 + Name: branchName, 87 + Hash: commitHash.String(), 88 + }, 89 + Commit: &object.Commit{ 90 + Hash: commitHash, 91 + Author: object.Signature{ 92 + Name: authorName, 93 + Email: authorEmail, 94 + When: authoredAt, 95 + }, 96 + Committer: object.Signature{ 97 + Name: committerName, 98 + Email: committerEmail, 99 + When: committedAt, 100 + }, 101 + TreeHash: treeHash, 102 + ParentHashes: []plumbing.Hash{parentHash}, 103 + Message: message, 104 + }, 105 + } 106 + 107 + branches = append(branches, branch) 108 + } 109 + 110 + slices.Reverse(branches) 111 + return branches, nil 112 + }
+42
knotserver/git/cmd.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "os/exec" 6 + ) 7 + 8 + const ( 9 + fieldSeparator = "\x1f" // ASCII Unit Separator 10 + recordSeparator = "\x1e" // ASCII Record Separator 11 + ) 12 + 13 + func (g *GitRepo) runGitCmd(command string, extraArgs ...string) ([]byte, error) { 14 + var args []string 15 + args = append(args, command) 16 + args = append(args, extraArgs...) 17 + 18 + cmd := exec.Command("git", args...) 19 + cmd.Dir = g.path 20 + 21 + out, err := cmd.Output() 22 + if err != nil { 23 + if exitErr, ok := err.(*exec.ExitError); ok { 24 + return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 25 + } 26 + return nil, err 27 + } 28 + 29 + return out, nil 30 + } 31 + 32 + func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 33 + return g.runGitCmd("rev-list", extraArgs...) 34 + } 35 + 36 + func (g *GitRepo) forEachRef(extraArgs ...string) ([]byte, error) { 37 + return g.runGitCmd("for-each-ref", extraArgs...) 38 + } 39 + 40 + func (g *GitRepo) revParse(extraArgs ...string) ([]byte, error) { 41 + return g.runGitCmd("rev-parse", extraArgs...) 42 + }
+3 -94
knotserver/git/git.go
··· 6 6 "fmt" 7 7 "io" 8 8 "io/fs" 9 - "os/exec" 10 9 "path" 11 - "sort" 12 10 "strconv" 13 11 "strings" 14 12 "time" ··· 16 14 "github.com/go-git/go-git/v5" 17 15 "github.com/go-git/go-git/v5/plumbing" 18 16 "github.com/go-git/go-git/v5/plumbing/object" 19 - "tangled.sh/tangled.sh/core/types" 20 17 ) 21 18 22 19 var ( ··· 170 167 return count, nil 171 168 } 172 169 173 - func (g *GitRepo) revList(extraArgs ...string) ([]byte, error) { 174 - var args []string 175 - args = append(args, "rev-list") 176 - args = append(args, extraArgs...) 177 - 178 - cmd := exec.Command("git", args...) 179 - cmd.Dir = g.path 180 - 181 - out, err := cmd.Output() 182 - if err != nil { 183 - if exitErr, ok := err.(*exec.ExitError); ok { 184 - return nil, fmt.Errorf("%w, stderr: %s", err, string(exitErr.Stderr)) 185 - } 186 - return nil, err 187 - } 188 - 189 - return out, nil 190 - } 191 - 192 170 func (g *GitRepo) Commit(h plumbing.Hash) (*object.Commit, error) { 193 171 return g.r.CommitObject(h) 194 172 } ··· 285 263 return io.ReadAll(reader) 286 264 } 287 265 288 - func (g *GitRepo) Tags() ([]*TagReference, error) { 289 - iter, err := g.r.Tags() 290 - if err != nil { 291 - return nil, fmt.Errorf("tag objects: %w", err) 292 - } 293 - 294 - tags := make([]*TagReference, 0) 295 - 296 - if err := iter.ForEach(func(ref *plumbing.Reference) error { 297 - obj, err := g.r.TagObject(ref.Hash()) 298 - switch err { 299 - case nil: 300 - tags = append(tags, &TagReference{ 301 - ref: ref, 302 - tag: obj, 303 - }) 304 - case plumbing.ErrObjectNotFound: 305 - tags = append(tags, &TagReference{ 306 - ref: ref, 307 - }) 308 - default: 309 - return err 310 - } 311 - return nil 312 - }); err != nil { 313 - return nil, err 314 - } 315 - 316 - tagList := &TagList{r: g.r, refs: tags} 317 - sort.Sort(tagList) 318 - return tags, nil 319 - } 320 - 321 - func (g *GitRepo) Branches() ([]types.Branch, error) { 322 - bi, err := g.r.Branches() 323 - if err != nil { 324 - return nil, fmt.Errorf("branchs: %w", err) 325 - } 326 - 327 - branches := []types.Branch{} 328 - 329 - defaultBranch, err := g.FindMainBranch() 330 - 331 - _ = bi.ForEach(func(ref *plumbing.Reference) error { 332 - b := types.Branch{} 333 - b.Hash = ref.Hash().String() 334 - b.Name = ref.Name().Short() 335 - 336 - // resolve commit that this branch points to 337 - commit, _ := g.Commit(ref.Hash()) 338 - if commit != nil { 339 - b.Commit = commit 340 - } 341 - 342 - if defaultBranch != "" && defaultBranch == b.Name { 343 - b.IsDefault = true 344 - } 345 - 346 - branches = append(branches, b) 347 - 348 - return nil 349 - }) 350 - 351 - return branches, nil 352 - } 353 - 354 266 func (g *GitRepo) Branch(name string) (*plumbing.Reference, error) { 355 267 ref, err := g.r.Reference(plumbing.NewBranchReferenceName(name), false) 356 268 if err != nil { ··· 370 282 } 371 283 372 284 func (g *GitRepo) FindMainBranch() (string, error) { 373 - ref, err := g.r.Head() 285 + output, err := g.revParse("--abbrev-ref", "HEAD") 374 286 if err != nil { 375 - return "", fmt.Errorf("unable to find main branch: %w", err) 376 - } 377 - if ref.Name().IsBranch() { 378 - return strings.TrimPrefix(string(ref.Name()), "refs/heads/"), nil 287 + return "", fmt.Errorf("failed to find main branch: %w", err) 379 288 } 380 289 381 - return "", fmt.Errorf("unable to find main branch: %w", err) 290 + return strings.TrimSpace(string(output)), nil 382 291 } 383 292 384 293 // WriteTar writes itself from a tree into a binary tar file format.
+66
knotserver/git/language.go
··· 1 + package git 2 + 3 + import ( 4 + "context" 5 + "path" 6 + 7 + "github.com/go-enry/go-enry/v2" 8 + "github.com/go-git/go-git/v5/plumbing/object" 9 + ) 10 + 11 + type LangBreakdown map[string]int64 12 + 13 + func (g *GitRepo) AnalyzeLanguages(ctx context.Context) (LangBreakdown, error) { 14 + sizes := make(map[string]int64) 15 + err := g.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error { 16 + filepath := path.Join(root, node.Name) 17 + 18 + content, err := g.FileContentN(filepath, 16*1024) // 16KB 19 + if err != nil { 20 + return nil 21 + } 22 + 23 + if enry.IsGenerated(filepath, content) { 24 + return nil 25 + } 26 + 27 + language := analyzeLanguage(node, content) 28 + if group := enry.GetLanguageGroup(language); group != "" { 29 + language = group 30 + } 31 + 32 + langType := enry.GetLanguageType(language) 33 + if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown { 34 + return nil 35 + } 36 + 37 + sz, _ := parent.Size(node.Name) 38 + sizes[language] += sz 39 + 40 + return nil 41 + }) 42 + 43 + if err != nil { 44 + return nil, err 45 + } 46 + 47 + return sizes, nil 48 + } 49 + 50 + func analyzeLanguage(node object.TreeEntry, content []byte) string { 51 + language, ok := enry.GetLanguageByExtension(node.Name) 52 + if ok { 53 + return language 54 + } 55 + 56 + language, ok = enry.GetLanguageByFilename(node.Name) 57 + if ok { 58 + return language 59 + } 60 + 61 + if len(content) == 0 { 62 + return enry.OtherLanguage 63 + } 64 + 65 + return enry.GetLanguage(node.Name, content) 66 + }
+57 -25
knotserver/git/post_receive.go
··· 2 2 3 3 import ( 4 4 "bufio" 5 + "context" 5 6 "fmt" 6 7 "io" 7 8 "strings" 9 + "time" 8 10 9 11 "tangled.sh/tangled.sh/core/api/tangled" 10 12 ··· 46 48 } 47 49 48 50 type RefUpdateMeta struct { 49 - CommitCount CommitCount 50 - IsDefaultRef bool 51 + CommitCount CommitCount 52 + IsDefaultRef bool 53 + LangBreakdown LangBreakdown 51 54 } 52 55 53 56 type CommitCount struct { ··· 57 60 func (g *GitRepo) RefUpdateMeta(line PostReceiveLine) RefUpdateMeta { 58 61 commitCount, err := g.newCommitCount(line) 59 62 if err != nil { 60 - // TODO: non-fatal, log this 63 + // TODO: log this 61 64 } 62 65 63 66 isDefaultRef, err := g.isDefaultBranch(line) 64 67 if err != nil { 65 - // TODO: non-fatal, log this 68 + // TODO: log this 69 + } 70 + 71 + ctx, cancel := context.WithTimeout(context.Background(), time.Second*2) 72 + defer cancel() 73 + breakdown, err := g.AnalyzeLanguages(ctx) 74 + if err != nil { 75 + // TODO: log this 66 76 } 67 77 68 78 return RefUpdateMeta{ 69 - CommitCount: commitCount, 70 - IsDefaultRef: isDefaultRef, 79 + CommitCount: commitCount, 80 + IsDefaultRef: isDefaultRef, 81 + LangBreakdown: breakdown, 71 82 } 72 83 } 73 84 ··· 77 88 ByEmail: byEmail, 78 89 } 79 90 80 - if !line.NewSha.IsZero() { 81 - output, err := g.revList( 82 - fmt.Sprintf("--max-count=%d", 100), 83 - fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String()), 84 - ) 85 - if err != nil { 86 - return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 87 - } 91 + if line.NewSha.IsZero() { 92 + return commitCount, nil 93 + } 88 94 89 - lines := strings.Split(strings.TrimSpace(string(output)), "\n") 90 - if len(lines) == 1 && lines[0] == "" { 91 - return commitCount, nil 92 - } 95 + args := []string{fmt.Sprintf("--max-count=%d", 100)} 93 96 94 - for _, item := range lines { 95 - obj, err := g.r.CommitObject(plumbing.NewHash(item)) 96 - if err != nil { 97 - continue 98 - } 99 - commitCount.ByEmail[obj.Author.Email] += 1 97 + if line.OldSha.IsZero() { 98 + // just git rev-list <newsha> 99 + args = append(args, line.NewSha.String()) 100 + } else { 101 + // git rev-list <oldsha>..<newsha> 102 + args = append(args, fmt.Sprintf("%s..%s", line.OldSha.String(), line.NewSha.String())) 103 + } 104 + 105 + output, err := g.revList(args...) 106 + if err != nil { 107 + return commitCount, fmt.Errorf("failed to run rev-list: %w", err) 108 + } 109 + 110 + lines := strings.Split(strings.TrimSpace(string(output)), "\n") 111 + if len(lines) == 1 && lines[0] == "" { 112 + return commitCount, nil 113 + } 114 + 115 + for _, item := range lines { 116 + obj, err := g.r.CommitObject(plumbing.NewHash(item)) 117 + if err != nil { 118 + continue 100 119 } 120 + commitCount.ByEmail[obj.Author.Email] += 1 101 121 } 102 122 103 123 return commitCount, nil ··· 126 146 }) 127 147 } 128 148 149 + var langs []*tangled.GitRefUpdate_Pair 150 + for lang, size := range m.LangBreakdown { 151 + langs = append(langs, &tangled.GitRefUpdate_Pair{ 152 + Lang: lang, 153 + Size: size, 154 + }) 155 + } 156 + langBreakdown := &tangled.GitRefUpdate_Meta_LangBreakdown{ 157 + Inputs: langs, 158 + } 159 + 129 160 return tangled.GitRefUpdate_Meta{ 130 161 CommitCount: &tangled.GitRefUpdate_Meta_CommitCount{ 131 162 ByEmail: byEmail, 132 163 }, 133 - IsDefaultRef: m.IsDefaultRef, 164 + IsDefaultRef: m.IsDefaultRef, 165 + LangBreakdown: langBreakdown, 134 166 } 135 167 }
+99
knotserver/git/tag.go
··· 1 + package git 2 + 3 + import ( 4 + "fmt" 5 + "slices" 6 + "strconv" 7 + "strings" 8 + "time" 9 + 10 + "github.com/go-git/go-git/v5/plumbing" 11 + "github.com/go-git/go-git/v5/plumbing/object" 12 + ) 13 + 14 + func (g *GitRepo) Tags() ([]object.Tag, error) { 15 + fields := []string{ 16 + "refname:short", 17 + "objectname", 18 + "objecttype", 19 + "*objectname", 20 + "*objecttype", 21 + "taggername", 22 + "taggeremail", 23 + "taggerdate:unix", 24 + "contents", 25 + } 26 + 27 + var outFormat strings.Builder 28 + outFormat.WriteString("--format=") 29 + for i, f := range fields { 30 + if i != 0 { 31 + outFormat.WriteString(fieldSeparator) 32 + } 33 + outFormat.WriteString(fmt.Sprintf("%%(%s)", f)) 34 + } 35 + outFormat.WriteString("") 36 + outFormat.WriteString(recordSeparator) 37 + 38 + output, err := g.forEachRef(outFormat.String(), "refs/tags") 39 + if err != nil { 40 + return nil, fmt.Errorf("failed to get tags: %w", err) 41 + } 42 + 43 + records := strings.Split(strings.TrimSpace(string(output)), recordSeparator) 44 + if len(records) == 1 && records[0] == "" { 45 + return nil, nil 46 + } 47 + 48 + tags := make([]object.Tag, 0, len(records)) 49 + 50 + for _, line := range records { 51 + parts := strings.SplitN(strings.TrimSpace(line), fieldSeparator, len(fields)) 52 + if len(parts) < 6 { 53 + continue 54 + } 55 + 56 + tagName := parts[0] 57 + objectHash := parts[1] 58 + objectType := parts[2] 59 + targetHash := parts[3] // dereferenced object hash (empty for lightweight tags) 60 + // targetType := parts[4] // dereferenced object type (empty for lightweight tags) 61 + taggerName := parts[5] 62 + taggerEmail := parts[6] 63 + taggerDate := parts[7] 64 + message := parts[8] 65 + 66 + // parse creation time 67 + var createdAt time.Time 68 + if unix, err := strconv.ParseInt(taggerDate, 10, 64); err == nil { 69 + createdAt = time.Unix(unix, 0) 70 + } 71 + 72 + // parse object type 73 + typ, err := plumbing.ParseObjectType(objectType) 74 + if err != nil { 75 + return nil, err 76 + } 77 + 78 + // strip email separators 79 + taggerEmail = strings.TrimSuffix(strings.TrimPrefix(taggerEmail, "<"), ">") 80 + 81 + tag := object.Tag{ 82 + Hash: plumbing.NewHash(objectHash), 83 + Name: tagName, 84 + Tagger: object.Signature{ 85 + Name: taggerName, 86 + Email: taggerEmail, 87 + When: createdAt, 88 + }, 89 + Message: message, 90 + TargetType: typ, 91 + Target: plumbing.NewHash(targetHash), 92 + } 93 + 94 + tags = append(tags, tag) 95 + } 96 + 97 + slices.Reverse(tags) 98 + return tags, nil 99 + }
+4 -2
knotserver/internal.go
··· 3 3 import ( 4 4 "context" 5 5 "encoding/json" 6 + "fmt" 6 7 "log/slog" 7 8 "net/http" 8 9 "path/filepath" ··· 115 116 return err 116 117 } 117 118 118 - gr, err := git.PlainOpen(repoPath) 119 + gr, err := git.Open(repoPath, line.Ref) 119 120 if err != nil { 120 - return err 121 + return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 121 122 } 122 123 123 124 meta := gr.RefUpdateMeta(line) 125 + 124 126 metaRecord := meta.AsRecord() 125 127 126 128 refUpdate := tangled.GitRefUpdate{
+21 -62
knotserver/routes.go
··· 13 13 "net/http" 14 14 "net/url" 15 15 "os" 16 - "path" 17 16 "path/filepath" 18 17 "strconv" 19 18 "strings" ··· 23 22 securejoin "github.com/cyphar/filepath-securejoin" 24 23 "github.com/gliderlabs/ssh" 25 24 "github.com/go-chi/chi/v5" 26 - "github.com/go-enry/go-enry/v2" 27 25 gogit "github.com/go-git/go-git/v5" 28 26 "github.com/go-git/go-git/v5/plumbing" 29 27 "github.com/go-git/go-git/v5/plumbing/object" ··· 96 94 total int 97 95 branches []types.Branch 98 96 files []types.NiceTree 99 - tags []*git.TagReference 97 + tags []object.Tag 100 98 ) 101 99 102 100 var wg sync.WaitGroup ··· 169 167 170 168 rtags := []*types.TagReference{} 171 169 for _, tag := range tags { 170 + var target *object.Tag 171 + if tag.Target != plumbing.ZeroHash { 172 + target = &tag 173 + } 172 174 tr := types.TagReference{ 173 - Tag: tag.TagObject(), 175 + Tag: target, 174 176 } 175 177 176 178 tr.Reference = types.Reference{ 177 - Name: tag.Name(), 178 - Hash: tag.Hash().String(), 179 + Name: tag.Name, 180 + Hash: tag.Hash.String(), 179 181 } 180 182 181 - if tag.Message() != "" { 182 - tr.Message = tag.Message() 183 + if tag.Message != "" { 184 + tr.Message = tag.Message 183 185 } 184 186 185 187 rtags = append(rtags, &tr) ··· 488 490 489 491 rtags := []*types.TagReference{} 490 492 for _, tag := range tags { 493 + var target *object.Tag 494 + if tag.Target != plumbing.ZeroHash { 495 + target = &tag 496 + } 491 497 tr := types.TagReference{ 492 - Tag: tag.TagObject(), 498 + Tag: target, 493 499 } 494 500 495 501 tr.Reference = types.Reference{ 496 - Name: tag.Name(), 497 - Hash: tag.Hash().String(), 502 + Name: tag.Name, 503 + Hash: tag.Hash.String(), 498 504 } 499 505 500 - if tag.Message() != "" { 501 - tr.Message = tag.Message() 506 + if tag.Message != "" { 507 + tr.Message = tag.Message 502 508 } 503 509 504 510 rtags = append(rtags, &tr) ··· 777 783 return 778 784 } 779 785 780 - sizes := make(map[string]int64) 781 - 782 786 ctx, cancel := context.WithTimeout(r.Context(), 1*time.Second) 783 787 defer cancel() 784 788 785 - err = gr.Walk(ctx, "", func(node object.TreeEntry, parent *object.Tree, root string) error { 786 - filepath := path.Join(root, node.Name) 787 - 788 - content, err := gr.FileContentN(filepath, 16*1024) // 16KB 789 - if err != nil { 790 - return nil 791 - } 792 - 793 - if enry.IsGenerated(filepath, content) { 794 - return nil 795 - } 796 - 797 - language := analyzeLanguage(node, content) 798 - if group := enry.GetLanguageGroup(language); group != "" { 799 - language = group 800 - } 801 - 802 - langType := enry.GetLanguageType(language) 803 - if langType != enry.Programming && langType != enry.Markup && langType != enry.Unknown { 804 - return nil 805 - } 806 - 807 - sz, _ := parent.Size(node.Name) 808 - sizes[language] += sz 809 - 810 - return nil 811 - }) 789 + sizes, err := gr.AnalyzeLanguages(ctx) 812 790 if err != nil { 813 - l.Error("failed to recurse file tree", "error", err.Error()) 791 + l.Error("failed to analyze languages", "error", err.Error()) 814 792 writeError(w, err.Error(), http.StatusNoContent) 815 793 return 816 794 } ··· 818 796 resp := types.RepoLanguageResponse{Languages: sizes} 819 797 820 798 writeJSON(w, resp) 821 - return 822 - } 823 - 824 - func analyzeLanguage(node object.TreeEntry, content []byte) string { 825 - language, ok := enry.GetLanguageByExtension(node.Name) 826 - if ok { 827 - return language 828 - } 829 - 830 - language, ok = enry.GetLanguageByFilename(node.Name) 831 - if ok { 832 - return language 833 - } 834 - 835 - if len(content) == 0 { 836 - return enry.OtherLanguage 837 - } 838 - 839 - return enry.GetLanguage(node.Name, content) 840 799 } 841 800 842 801 func (h *Handle) RepoForkSync(w http.ResponseWriter, r *http.Request) {
+34
lexicons/feed/reaction.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "sh.tangled.feed.reaction", 4 + "needsCbor": true, 5 + "needsType": true, 6 + "defs": { 7 + "main": { 8 + "type": "record", 9 + "key": "tid", 10 + "record": { 11 + "type": "object", 12 + "required": [ 13 + "subject", 14 + "reaction", 15 + "createdAt" 16 + ], 17 + "properties": { 18 + "subject": { 19 + "type": "string", 20 + "format": "at-uri" 21 + }, 22 + "reaction": { 23 + "type": "string", 24 + "enum": [ "๐Ÿ‘", "๐Ÿ‘Ž", "๐Ÿ˜†", "๐ŸŽ‰", "๐Ÿซค", "โค๏ธ", "๐Ÿš€", "๐Ÿ‘€" ] 25 + }, 26 + "createdAt": { 27 + "type": "string", 28 + "format": "datetime" 29 + } 30 + } 31 + } 32 + } 33 + } 34 + }
+27
lexicons/git/refUpdate.json
··· 61 61 "type": "boolean", 62 62 "default": "false" 63 63 }, 64 + "langBreakdown": { 65 + "type": "object", 66 + "properties": { 67 + "inputs": { 68 + "type": "array", 69 + "items": { 70 + "type": "ref", 71 + "ref": "#pair" 72 + } 73 + } 74 + } 75 + }, 64 76 "commitCount": { 65 77 "type": "object", 66 78 "required": [], ··· 87 99 } 88 100 } 89 101 } 102 + } 103 + } 104 + }, 105 + "pair": { 106 + "type": "object", 107 + "required": [ 108 + "lang", 109 + "size" 110 + ], 111 + "properties": { 112 + "lang": { 113 + "type": "string" 114 + }, 115 + "size": { 116 + "type": "integer" 90 117 } 91 118 } 92 119 }
+454
nix/gomod2nix.toml
··· 1 + schema = 3 2 + 3 + [mod] 4 + [mod."dario.cat/mergo"] 5 + version = "v1.0.1" 6 + hash = "sha256-wcG6+x0k6KzOSlaPA+1RFxa06/RIAePJTAjjuhLbImw=" 7 + [mod."github.com/Blank-Xu/sql-adapter"] 8 + version = "v1.1.1" 9 + hash = "sha256-9AiQhXoNPCiViV+p5aa3qGFkYU4rJNbADvNdYGq4GA4=" 10 + [mod."github.com/Microsoft/go-winio"] 11 + version = "v0.6.2" 12 + hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" 13 + [mod."github.com/ProtonMail/go-crypto"] 14 + version = "v1.2.0" 15 + hash = "sha256-5fKgWUz6BoyFNNZ1OD9QjhBrhNEBCuVfO2WqH+X59oo=" 16 + [mod."github.com/alecthomas/chroma/v2"] 17 + version = "v2.19.0" 18 + hash = "sha256-dxsu43a+PvHg2jYR0Tfys6a8x6IVR+9oCGAh+fvL3SM=" 19 + replaced = "github.com/oppiliappan/chroma/v2" 20 + [mod."github.com/anmitsu/go-shlex"] 21 + version = "v0.0.0-20200514113438-38f4b401e2be" 22 + hash = "sha256-L3Ak4X2z7WXq7vMKuiHCOJ29nlpajUQ08Sfb9T0yP54=" 23 + [mod."github.com/avast/retry-go/v4"] 24 + version = "v4.6.1" 25 + hash = "sha256-PeZc8k4rDV64+k8nZt/oy1YNVbLevltXP3ZD1jf6Z6k=" 26 + [mod."github.com/aymerick/douceur"] 27 + version = "v0.2.0" 28 + hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE=" 29 + [mod."github.com/beorn7/perks"] 30 + version = "v1.0.1" 31 + hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" 32 + [mod."github.com/bluekeyes/go-gitdiff"] 33 + version = "v0.8.2" 34 + hash = "sha256-GWm5i1ukuBukV0GMF1rffpbOSSXZdfg6/0pABMiGzLQ=" 35 + replaced = "tangled.sh/oppi.li/go-gitdiff" 36 + [mod."github.com/bluesky-social/indigo"] 37 + version = "v0.0.0-20250520232546-236dd575c91e" 38 + hash = "sha256-SmwhGkAKcB/oGwYP68U5192fAUhui6D0GWYiJOeB1/0=" 39 + [mod."github.com/bluesky-social/jetstream"] 40 + version = "v0.0.0-20241210005130-ea96859b93d1" 41 + hash = "sha256-AiapbrkjXboIKc5QNiWH0KyNs0zKnn6UlGwWFlkUfm0=" 42 + [mod."github.com/bmatcuk/doublestar/v4"] 43 + version = "v4.7.1" 44 + hash = "sha256-idO38nWZtmxvE0CgdziepwFM+Xc70Isr6NiKEZjHOjA=" 45 + [mod."github.com/carlmjohnson/versioninfo"] 46 + version = "v0.22.5" 47 + hash = "sha256-tf7yKVFTUPmGKBLK43bjyIRQUboCYduh3I5HXE5+LPw=" 48 + [mod."github.com/casbin/casbin/v2"] 49 + version = "v2.103.0" 50 + hash = "sha256-adYds8Arni/ioPM9J0F+wAlJqhLLtCV9epv7d7tDvAQ=" 51 + [mod."github.com/casbin/govaluate"] 52 + version = "v1.3.0" 53 + hash = "sha256-vDUFEGt8oL4n/PHwlMZPjmaLvcpGTN4HEIRGl2FPxUA=" 54 + [mod."github.com/cespare/xxhash/v2"] 55 + version = "v2.3.0" 56 + hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" 57 + [mod."github.com/cloudflare/circl"] 58 + version = "v1.6.0" 59 + hash = "sha256-a+SVfnHYC8Fb+NQLboNg5P9sry+WutzuNetVHFVAAo0=" 60 + [mod."github.com/containerd/errdefs"] 61 + version = "v1.0.0" 62 + hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" 63 + [mod."github.com/containerd/errdefs/pkg"] 64 + version = "v0.3.0" 65 + hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" 66 + [mod."github.com/containerd/log"] 67 + version = "v0.1.0" 68 + hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s=" 69 + [mod."github.com/cyphar/filepath-securejoin"] 70 + version = "v0.4.1" 71 + hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" 72 + [mod."github.com/davecgh/go-spew"] 73 + version = "v1.1.2-0.20180830191138-d8f796af33cc" 74 + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" 75 + [mod."github.com/decred/dcrd/dcrec/secp256k1/v4"] 76 + version = "v4.4.0" 77 + hash = "sha256-qrhEIwhDll3cxoVpMbm1NQ9/HTI42S7ms8Buzlo5HCg=" 78 + [mod."github.com/dgraph-io/ristretto"] 79 + version = "v0.2.0" 80 + hash = "sha256-bnpxX+oO/Qf7IJevA0gsbloVoqRx+5bh7RQ9d9eLNYw=" 81 + [mod."github.com/dgryski/go-rendezvous"] 82 + version = "v0.0.0-20200823014737-9f7001d12a5f" 83 + hash = "sha256-n/7xo5CQqo4yLaWMSzSN1Muk/oqK6O5dgDOFWapeDUI=" 84 + [mod."github.com/distribution/reference"] 85 + version = "v0.6.0" 86 + hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" 87 + [mod."github.com/dlclark/regexp2"] 88 + version = "v1.11.5" 89 + hash = "sha256-jN5+2ED+YbIoPIuyJ4Ou5pqJb2w1uNKzp5yTjKY6rEQ=" 90 + [mod."github.com/docker/docker"] 91 + version = "v28.2.2+incompatible" 92 + hash = "sha256-5FnlTcygdxpHyFB0/7EsYocFhADUAjC/Dku0Xn4W8so=" 93 + [mod."github.com/docker/go-connections"] 94 + version = "v0.5.0" 95 + hash = "sha256-aGbMRrguh98DupIHgcpLkVUZpwycx1noQXbtTl5Sbms=" 96 + [mod."github.com/docker/go-units"] 97 + version = "v0.5.0" 98 + hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" 99 + [mod."github.com/dustin/go-humanize"] 100 + version = "v1.0.1" 101 + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" 102 + [mod."github.com/emirpasic/gods"] 103 + version = "v1.18.1" 104 + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" 105 + [mod."github.com/felixge/httpsnoop"] 106 + version = "v1.0.4" 107 + hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" 108 + [mod."github.com/gliderlabs/ssh"] 109 + version = "v0.3.8" 110 + hash = "sha256-FW+91qCB3rfTm0I1VmqfwA7o+2kDys2JHOudKKyxWwc=" 111 + [mod."github.com/go-chi/chi/v5"] 112 + version = "v5.2.0" 113 + hash = "sha256-rCZ2W5BdWwjtv7SSpHOgpYEHf9ketzdPX+r2500JL8A=" 114 + [mod."github.com/go-enry/go-enry/v2"] 115 + version = "v2.9.2" 116 + hash = "sha256-LkCSW+4+DkTok1JcOQR0rt3UKNKVn4KPaiDeatdQhCU=" 117 + [mod."github.com/go-enry/go-oniguruma"] 118 + version = "v1.2.1" 119 + hash = "sha256-DoCNyX75CuCgFnfSZs63VB4+HAIMDBgwcQglXXHRj/I=" 120 + [mod."github.com/go-git/gcfg"] 121 + version = "v1.5.1-0.20230307220236-3a3c6141e376" 122 + hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" 123 + [mod."github.com/go-git/go-billy/v5"] 124 + version = "v5.6.2" 125 + hash = "sha256-VgbxcLkHjiSyRIfKS7E9Sn8OynCrMGUDkwFz6K2TVL4=" 126 + [mod."github.com/go-git/go-git/v5"] 127 + version = "v5.17.0" 128 + hash = "sha256-gya68abB6GtejUqr60DyU7NIGtNzHQVCAeDTYKk1evQ=" 129 + replaced = "github.com/oppiliappan/go-git/v5" 130 + [mod."github.com/go-logr/logr"] 131 + version = "v1.4.2" 132 + hash = "sha256-/W6qGilFlZNTb9Uq48xGZ4IbsVeSwJiAMLw4wiNYHLI=" 133 + [mod."github.com/go-logr/stdr"] 134 + version = "v1.2.2" 135 + hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" 136 + [mod."github.com/go-redis/cache/v9"] 137 + version = "v9.0.0" 138 + hash = "sha256-b4S3K4KoZhF0otw6FRIOq/PTdHGrb/LumB4GKo4khsY=" 139 + [mod."github.com/goccy/go-json"] 140 + version = "v0.10.5" 141 + hash = "sha256-/EtlGihP0/7oInzMC5E0InZ4b5Ad3s4xOpqotloi3xw=" 142 + [mod."github.com/gogo/protobuf"] 143 + version = "v1.3.2" 144 + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" 145 + [mod."github.com/golang-jwt/jwt/v5"] 146 + version = "v5.2.2" 147 + hash = "sha256-C0MhDguxWR6dQUrNVQ5xaFUReSV6CVEBAijG3b4wnX4=" 148 + [mod."github.com/golang/groupcache"] 149 + version = "v0.0.0-20241129210726-2c02b8208cf8" 150 + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" 151 + [mod."github.com/google/uuid"] 152 + version = "v1.6.0" 153 + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" 154 + [mod."github.com/gorilla/css"] 155 + version = "v1.0.1" 156 + hash = "sha256-6JwNHqlY2NpZ0pSQTyYPSpiNqjXOdFHqrUT10sv3y8A=" 157 + [mod."github.com/gorilla/securecookie"] 158 + version = "v1.1.2" 159 + hash = "sha256-KeMHNM9emxX+N0WYiZsTii7n8sNsmjWwbnQ9SaJfTKE=" 160 + [mod."github.com/gorilla/sessions"] 161 + version = "v1.4.0" 162 + hash = "sha256-cLK2z1uOEz7Wah/LclF65ptYMqzuvaRnfIGYqtn3b7g=" 163 + [mod."github.com/gorilla/websocket"] 164 + version = "v1.5.3" 165 + hash = "sha256-vTIGEFMEi+30ZdO6ffMNJ/kId6pZs5bbyqov8xe9BM0=" 166 + [mod."github.com/hashicorp/go-cleanhttp"] 167 + version = "v0.5.2" 168 + hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" 169 + [mod."github.com/hashicorp/go-retryablehttp"] 170 + version = "v0.7.7" 171 + hash = "sha256-XZjxncyLPwy6YBHR3DF5bEl1y72or0JDUncTIsb/eIU=" 172 + [mod."github.com/hashicorp/golang-lru"] 173 + version = "v1.0.2" 174 + hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48=" 175 + [mod."github.com/hashicorp/golang-lru/v2"] 176 + version = "v2.0.7" 177 + hash = "sha256-t1bcXLgrQNOYUVyYEZ0knxcXpsTk4IuJZDjKvyJX75g=" 178 + [mod."github.com/hiddeco/sshsig"] 179 + version = "v0.2.0" 180 + hash = "sha256-Yc8Ip4XxrL5plb7Lq0ziYFznteVDZnskoyOZDIMsWOU=" 181 + [mod."github.com/hpcloud/tail"] 182 + version = "v1.0.0" 183 + hash = "sha256-7ByBr/RcOwIsGPCiCUpfNwUSvU18QAY+HMnCJr8uU1w=" 184 + [mod."github.com/ipfs/bbloom"] 185 + version = "v0.0.4" 186 + hash = "sha256-4k778kBlNul2Rc4xuNQ9WA4kT0V7x5X9odZrT+2xjTU=" 187 + [mod."github.com/ipfs/boxo"] 188 + version = "v0.30.0" 189 + hash = "sha256-PWH+nlIZZlqB/PuiBX9X4McLZF4gKR1MEnjvutKT848=" 190 + [mod."github.com/ipfs/go-block-format"] 191 + version = "v0.2.1" 192 + hash = "sha256-npEV0Axe6zJlzN00/GwiegE9HKsuDR6RhsAfPyphOl8=" 193 + [mod."github.com/ipfs/go-cid"] 194 + version = "v0.5.0" 195 + hash = "sha256-BuZKkcBXrnx7mM1c9SP4LdzZoaAoai9U49vITGrYJQk=" 196 + [mod."github.com/ipfs/go-datastore"] 197 + version = "v0.8.2" 198 + hash = "sha256-9Q7+bi04srAE3AcXzWSGs/HP6DWnE1Edtx3NnjMQi8U=" 199 + [mod."github.com/ipfs/go-ipfs-blockstore"] 200 + version = "v1.3.1" 201 + hash = "sha256-NFlKr8bdJcM5FLlkc51sKt4AnMMlHS4wbdKiiaoDaqg=" 202 + [mod."github.com/ipfs/go-ipfs-ds-help"] 203 + version = "v1.1.1" 204 + hash = "sha256-cpEohOsf4afYRGTdsWh84TCVGIDzJo2hSjWy7NtNtvY=" 205 + [mod."github.com/ipfs/go-ipld-cbor"] 206 + version = "v0.2.0" 207 + hash = "sha256-bvHFCIQqim3/+xzl1bld3NxKY8WoeCO3HpdTfUsXvlc=" 208 + [mod."github.com/ipfs/go-ipld-format"] 209 + version = "v0.6.1" 210 + hash = "sha256-v1zLYYGaoDxsgOW5joQGWHEHZoJjIXc6tLVgTomZ2z4=" 211 + [mod."github.com/ipfs/go-log"] 212 + version = "v1.0.5" 213 + hash = "sha256-WQarHZo2y/rH6ixLsOlN5fFZeLUqsOTMnvdxszP2Qj4=" 214 + [mod."github.com/ipfs/go-log/v2"] 215 + version = "v2.6.0" 216 + hash = "sha256-cZ+rsx7LIROoNITyu/s0B6hq8lNQsUC1ynvx2f2o4Gk=" 217 + [mod."github.com/ipfs/go-metrics-interface"] 218 + version = "v0.3.0" 219 + hash = "sha256-b3tp3jxecLmJEGx2kW7MiKGlAKPEWg/LJ7hXylSC8jQ=" 220 + [mod."github.com/kevinburke/ssh_config"] 221 + version = "v1.2.0" 222 + hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" 223 + [mod."github.com/klauspost/compress"] 224 + version = "v1.18.0" 225 + hash = "sha256-jc5pMU/HCBFOShMcngVwNMhz9wolxjOb579868LtOuk=" 226 + [mod."github.com/klauspost/cpuid/v2"] 227 + version = "v2.2.10" 228 + hash = "sha256-o21Tk5sD7WhhLUoqSkymnjLbzxl0mDJCTC1ApfZJrC0=" 229 + [mod."github.com/lestrrat-go/blackmagic"] 230 + version = "v1.0.3" 231 + hash = "sha256-1wyfD6fPopJF/UmzfAEa0N1zuUzVuHIpdcxks1kqxxw=" 232 + [mod."github.com/lestrrat-go/httpcc"] 233 + version = "v1.0.1" 234 + hash = "sha256-SMRSwJpqDIs/xL0l2e8vP0W65qtCHX2wigcOeqPJmos=" 235 + [mod."github.com/lestrrat-go/httprc"] 236 + version = "v1.0.6" 237 + hash = "sha256-mfZzePEhrmyyu/avEBd2MsDXyto8dq5+fyu5lA8GUWM=" 238 + [mod."github.com/lestrrat-go/iter"] 239 + version = "v1.0.2" 240 + hash = "sha256-30tErRf7Qu/NOAt1YURXY/XJSA6sCr6hYQfO8QqHrtw=" 241 + [mod."github.com/lestrrat-go/jwx/v2"] 242 + version = "v2.1.6" 243 + hash = "sha256-0LszXRZIba+X8AOrs3T4uanAUafBdlVB8/MpUNEFpbc=" 244 + [mod."github.com/lestrrat-go/option"] 245 + version = "v1.0.1" 246 + hash = "sha256-jVcIYYVsxElIS/l2akEw32vdEPR8+anR6oeT1FoYULI=" 247 + [mod."github.com/mattn/go-isatty"] 248 + version = "v0.0.20" 249 + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" 250 + [mod."github.com/mattn/go-sqlite3"] 251 + version = "v1.14.24" 252 + hash = "sha256-taGKFZFQlR5++5b2oZ1dYS3RERKv6yh1gniNWhb4egg=" 253 + [mod."github.com/microcosm-cc/bluemonday"] 254 + version = "v1.0.27" 255 + hash = "sha256-EZSya9FLPQ83CL7N2cZy21fdS35hViTkiMK5f3op8Es=" 256 + [mod."github.com/minio/sha256-simd"] 257 + version = "v1.0.1" 258 + hash = "sha256-4hfGDIQaWq8fvtGzHDhoK9v2IocXnJY7OAL6saMJbmA=" 259 + [mod."github.com/moby/docker-image-spec"] 260 + version = "v1.3.1" 261 + hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" 262 + [mod."github.com/moby/sys/atomicwriter"] 263 + version = "v0.1.0" 264 + hash = "sha256-i46GNrsICnJ0AYkN+ocbVZ2GNTQVEsrVX5WcjKzjtBM=" 265 + [mod."github.com/moby/term"] 266 + version = "v0.5.2" 267 + hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" 268 + [mod."github.com/morikuni/aec"] 269 + version = "v1.0.0" 270 + hash = "sha256-5zYgLeGr3K+uhGKlN3xv0PO67V+2Zw+cezjzNCmAWOE=" 271 + [mod."github.com/mr-tron/base58"] 272 + version = "v1.2.0" 273 + hash = "sha256-8FzMu3kHUbBX10pUdtGf59Ag7BNupx8ZHeUaodR1/Vk=" 274 + [mod."github.com/multiformats/go-base32"] 275 + version = "v0.1.0" 276 + hash = "sha256-O2IM7FB+Y9MkDdZztyQL5F8oEnmON2Yew7XkotQziio=" 277 + [mod."github.com/multiformats/go-base36"] 278 + version = "v0.2.0" 279 + hash = "sha256-GKNnAGA0Lb39BDGYBm1ieKdXmho8Pu7ouyfVPXvV0PE=" 280 + [mod."github.com/multiformats/go-multibase"] 281 + version = "v0.2.0" 282 + hash = "sha256-w+hp6u5bWyd34qe0CX+bq487ADqq6SgRR/JuqRB578s=" 283 + [mod."github.com/multiformats/go-multihash"] 284 + version = "v0.2.3" 285 + hash = "sha256-zqIIE5jMFzm+qhUrouSF+WdXGeHUEYIQvVnKWWU6mRs=" 286 + [mod."github.com/multiformats/go-varint"] 287 + version = "v0.0.7" 288 + hash = "sha256-To3Uuv7uSUJEr5OTwxE1LEIpA62xY3M/KKMNlscHmlA=" 289 + [mod."github.com/munnerz/goautoneg"] 290 + version = "v0.0.0-20191010083416-a7dc8b61c822" 291 + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" 292 + [mod."github.com/opencontainers/go-digest"] 293 + version = "v1.0.0" 294 + hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" 295 + [mod."github.com/opencontainers/image-spec"] 296 + version = "v1.1.1" 297 + hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" 298 + [mod."github.com/opentracing/opentracing-go"] 299 + version = "v1.2.0" 300 + hash = "sha256-kKTKFGXOsCF6QdVzI++GgaRzv2W+kWq5uDXOJChvLxM=" 301 + [mod."github.com/pjbgf/sha1cd"] 302 + version = "v0.3.2" 303 + hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" 304 + [mod."github.com/pkg/errors"] 305 + version = "v0.9.1" 306 + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" 307 + [mod."github.com/pmezard/go-difflib"] 308 + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" 309 + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" 310 + [mod."github.com/polydawn/refmt"] 311 + version = "v0.89.1-0.20221221234430-40501e09de1f" 312 + hash = "sha256-wBdFROClTHNPYU4IjeKbBXaG7F6j5hZe15gMxiqKvi4=" 313 + [mod."github.com/posthog/posthog-go"] 314 + version = "v1.5.5" 315 + hash = "sha256-ouhfDUCXsfpcgaCLfJE9oYprAQHuV61OJzb/aEhT0j8=" 316 + [mod."github.com/prometheus/client_golang"] 317 + version = "v1.22.0" 318 + hash = "sha256-OJ/9rlWG1DIPQJAZUTzjykkX0o+f+4IKLvW8YityaMQ=" 319 + [mod."github.com/prometheus/client_model"] 320 + version = "v0.6.2" 321 + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" 322 + [mod."github.com/prometheus/common"] 323 + version = "v0.63.0" 324 + hash = "sha256-TbUZNkN4ZA7eC/MlL1v2V5OL28QRnftSuaWQZ944zBE=" 325 + [mod."github.com/prometheus/procfs"] 326 + version = "v0.16.1" 327 + hash = "sha256-OBCvKlLW2obct35p0L9Q+1ZrxZjpTmbgHMP2rng9hpo=" 328 + [mod."github.com/redis/go-redis/v9"] 329 + version = "v9.3.0" 330 + hash = "sha256-PNXDX3BH92d2jL/AkdK0eWMorh387Y6duwYNhsqNe+w=" 331 + [mod."github.com/resend/resend-go/v2"] 332 + version = "v2.15.0" 333 + hash = "sha256-1lMoxuMLQXaNWFKadS6rpztAKwvIl3/LWMXqw7f5WYg=" 334 + [mod."github.com/segmentio/asm"] 335 + version = "v1.2.0" 336 + hash = "sha256-zbNuKxNrUDUc6IlmRQNuJQzVe5Ol/mqp7srDg9IMMqs=" 337 + [mod."github.com/sergi/go-diff"] 338 + version = "v1.1.0" 339 + hash = "sha256-8NJMabldpf40uwQN20T6QXx5KORDibCBJL02KD661xY=" 340 + replaced = "github.com/sergi/go-diff" 341 + [mod."github.com/sethvargo/go-envconfig"] 342 + version = "v1.1.0" 343 + hash = "sha256-WelRHfyZG9hrA4fbQcfBawb2ZXBQNT1ourEYHzQdZ4w=" 344 + [mod."github.com/spaolacci/murmur3"] 345 + version = "v1.1.0" 346 + hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M=" 347 + [mod."github.com/stretchr/testify"] 348 + version = "v1.10.0" 349 + hash = "sha256-fJ4gnPr0vnrOhjQYQwJ3ARDKPsOtA7d4olQmQWR+wpI=" 350 + [mod."github.com/urfave/cli/v3"] 351 + version = "v3.3.3" 352 + hash = "sha256-FdPiu7koY1qBinkfca4A05zCrX+Vu4eRz8wlRDZJyGg=" 353 + [mod."github.com/vmihailenco/go-tinylfu"] 354 + version = "v0.2.2" 355 + hash = "sha256-ZHr4g7DJAV6rLcfrEWZwo9wJSeZcXB9KSP38UIOFfaM=" 356 + [mod."github.com/vmihailenco/msgpack/v5"] 357 + version = "v5.4.1" 358 + hash = "sha256-pDplX6xU6UpNLcFbO1pRREW5vCnSPvSU+ojAwFDv3Hk=" 359 + [mod."github.com/vmihailenco/tagparser/v2"] 360 + version = "v2.0.0" 361 + hash = "sha256-M9QyaKhSmmYwsJk7gkjtqu9PuiqZHSmTkous8VWkWY0=" 362 + [mod."github.com/whyrusleeping/cbor-gen"] 363 + version = "v0.3.1" 364 + hash = "sha256-PAd8M2Z8t6rVRBII+Rg8Bz+QaJIwbW64bfyqsv31kgc=" 365 + [mod."github.com/yuin/goldmark"] 366 + version = "v1.4.13" 367 + hash = "sha256-GVwFKZY6moIS6I0ZGuio/WtDif+lkZRfqWS6b4AAJyI=" 368 + [mod."gitlab.com/yawning/secp256k1-voi"] 369 + version = "v0.0.0-20230925100816-f2616030848b" 370 + hash = "sha256-X8INg01LTg13iOuwPI3uOhPN7r01sPZtmtwJ2sudjCA=" 371 + [mod."gitlab.com/yawning/tuplehash"] 372 + version = "v0.0.0-20230713102510-df83abbf9a02" 373 + hash = "sha256-pehQduoaJRLchebhgvMYacVvbuNIBA++XkiqCuqdato=" 374 + [mod."go.opentelemetry.io/auto/sdk"] 375 + version = "v1.1.0" 376 + hash = "sha256-cA9qCCu8P1NSJRxgmpfkfa5rKyn9X+Y/9FSmSd5xjyo=" 377 + [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] 378 + version = "v0.61.0" 379 + hash = "sha256-4pfXD7ErXhexSynXiEEQSAkWoPwHd7PEDE3M1Zi5gLM=" 380 + [mod."go.opentelemetry.io/otel"] 381 + version = "v1.36.0" 382 + hash = "sha256-j8wojdCtKal3LKojanHA8KXXQ0FkbWONpO8tUxpJDko=" 383 + [mod."go.opentelemetry.io/otel/metric"] 384 + version = "v1.36.0" 385 + hash = "sha256-z6Uqi4HhUljWIYd58svKK5MqcGbpcac+/M8JeTrUtJ8=" 386 + [mod."go.opentelemetry.io/otel/trace"] 387 + version = "v1.36.0" 388 + hash = "sha256-owWD9x1lp8aIJqYt058BXPUsIMHdk3RI0escso0BxwA=" 389 + [mod."go.opentelemetry.io/proto/otlp"] 390 + version = "v1.6.0" 391 + hash = "sha256-1kjkJ9cqkvx3ib6ytLcw+Adp4xqD3ShF97lpzt/BeCg=" 392 + [mod."go.uber.org/atomic"] 393 + version = "v1.11.0" 394 + hash = "sha256-TyYws/cSPVqYNffFX0gbDml1bD4bBGcysrUWU7mHPIY=" 395 + [mod."go.uber.org/multierr"] 396 + version = "v1.11.0" 397 + hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" 398 + [mod."go.uber.org/zap"] 399 + version = "v1.27.0" 400 + hash = "sha256-8655KDrulc4Das3VRduO9MjCn8ZYD5WkULjCvruaYsU=" 401 + [mod."golang.org/x/crypto"] 402 + version = "v0.38.0" 403 + hash = "sha256-5tTXlXQBlfW1sSNDAIalOpsERbTJlZqbwCIiih4T4rY=" 404 + [mod."golang.org/x/exp"] 405 + version = "v0.0.0-20250408133849-7e4ce0ab07d0" 406 + hash = "sha256-Lw/WupSM8gcq0JzPSAaBqj9l1uZ68ANhaIaQzPhRpy8=" 407 + [mod."golang.org/x/net"] 408 + version = "v0.40.0" 409 + hash = "sha256-BhDOHTP8RekXDQDf9HlORSmI2aPacLo53fRXtTgCUH8=" 410 + [mod."golang.org/x/sync"] 411 + version = "v0.14.0" 412 + hash = "sha256-YNQLeFMeXN9y0z4OyXV/LJ4hA54q+ljm1ytcy80O6r4=" 413 + [mod."golang.org/x/sys"] 414 + version = "v0.33.0" 415 + hash = "sha256-wlOzIOUgAiGAtdzhW/KPl/yUVSH/lvFZfs5XOuJ9LOQ=" 416 + [mod."golang.org/x/time"] 417 + version = "v0.8.0" 418 + hash = "sha256-EA+qRisDJDPQ2g4pcfP4RyQaB7CJKkAn68EbNfBzXdQ=" 419 + [mod."golang.org/x/xerrors"] 420 + version = "v0.0.0-20240903120638-7835f813f4da" 421 + hash = "sha256-bE7CcrnAvryNvM26ieJGXqbAtuLwHaGcmtVMsVnksqo=" 422 + [mod."google.golang.org/genproto/googleapis/api"] 423 + version = "v0.0.0-20250519155744-55703ea1f237" 424 + hash = "sha256-ivktx8ipWgWZgchh4FjKoWL7kU8kl/TtIavtZq/F5SQ=" 425 + [mod."google.golang.org/genproto/googleapis/rpc"] 426 + version = "v0.0.0-20250519155744-55703ea1f237" 427 + hash = "sha256-WK7iDtAhH19NPe3TywTQlGjDawNaDKWnxhFL9PgVUwM=" 428 + [mod."google.golang.org/grpc"] 429 + version = "v1.72.1" 430 + hash = "sha256-5JczomNvroKWtIYKDgXwaIaEfuNEK//MHPhJQiaxMXs=" 431 + [mod."google.golang.org/protobuf"] 432 + version = "v1.36.6" 433 + hash = "sha256-lT5qnefI5FDJnowz9PEkAGylH3+fE+A3DJDkAyy9RMc=" 434 + [mod."gopkg.in/fsnotify.v1"] 435 + version = "v1.4.7" 436 + hash = "sha256-j/Ts92oXa3k1MFU7Yd8/AqafRTsFn7V2pDKCyDJLah8=" 437 + [mod."gopkg.in/tomb.v1"] 438 + version = "v1.0.0-20141024135613-dd632973f1e7" 439 + hash = "sha256-W/4wBAvuaBFHhowB67SZZfXCRDp5tzbYG4vo81TAFdM=" 440 + [mod."gopkg.in/warnings.v0"] 441 + version = "v0.1.2" 442 + hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" 443 + [mod."gopkg.in/yaml.v3"] 444 + version = "v3.0.1" 445 + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" 446 + [mod."gotest.tools/v3"] 447 + version = "v3.5.2" 448 + hash = "sha256-eAxnRrF2bQugeFYzGLOr+4sLyCPOpaTWpoZsIKNP1WE=" 449 + [mod."lukechampine.com/blake3"] 450 + version = "v1.4.1" 451 + hash = "sha256-HaZGo9L44ptPsgxIhvKy3+0KZZm1+xt+cZC1rDQA9Yc=" 452 + [mod."tangled.sh/icyphox.sh/atproto-oauth"] 453 + version = "v0.0.0-20250526154904-3906c5336421" 454 + hash = "sha256-CvR8jic0YZfj0a8ubPj06FiMMR/1K9kHoZhLQw1LItM="
+2 -2
nix/modules/spindle.nix
··· 60 60 description = "Nixery instance to use"; 61 61 }; 62 62 63 - stepTimeout = mkOption { 63 + workflowTimeout = mkOption { 64 64 type = types.str; 65 65 default = "5m"; 66 66 description = "Timeout for each step of a pipeline"; ··· 87 87 "SPINDLE_SERVER_DEV=${lib.boolToString cfg.server.dev}" 88 88 "SPINDLE_SERVER_OWNER=${cfg.server.owner}" 89 89 "SPINDLE_PIPELINES_NIXERY=${cfg.pipelines.nixery}" 90 - "SPINDLE_PIPELINES_STEP_TIMEOUT=${cfg.pipelines.stepTimeout}" 90 + "SPINDLE_PIPELINES_WORKFLOW_TIMEOUT=${cfg.pipelines.workflowTimeout}" 91 91 ]; 92 92 ExecStart = "${self.packages.${pkgs.system}.spindle}/bin/spindle"; 93 93 Restart = "always";
+6 -9
nix/pkgs/appview.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 htmx-src, 5 5 htmx-ws-src, 6 6 lucide-src, ··· 8 8 ibm-plex-mono-src, 9 9 tailwindcss, 10 10 sqlite-lib, 11 - goModHash, 12 11 gitignoreSource, 13 12 }: 14 - buildGoModule { 15 - inherit stdenv; 16 - 13 + buildGoApplication { 17 14 pname = "appview"; 18 15 version = "0.1.0"; 19 16 src = gitignoreSource ../..; 17 + inherit modules; 20 18 21 19 postUnpack = '' 22 20 pushd source ··· 33 31 34 32 doCheck = false; 35 33 subPackages = ["cmd/appview"]; 36 - vendorHash = goModHash; 37 34 38 - tags = "libsqlite3"; 35 + tags = ["libsqlite3"]; 39 36 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 40 37 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 41 - env.CGO_ENABLED = 1; 38 + CGO_ENABLED = 1; 42 39 }
+5 -5
nix/pkgs/genjwks.nix
··· 1 1 { 2 - buildGoModule, 3 - goModHash, 4 2 gitignoreSource, 3 + buildGoApplication, 4 + modules, 5 5 }: 6 - buildGoModule { 6 + buildGoApplication { 7 7 pname = "genjwks"; 8 8 version = "0.1.0"; 9 9 src = gitignoreSource ../..; 10 + inherit modules; 10 11 subPackages = ["cmd/genjwks"]; 11 - vendorHash = goModHash; 12 12 doCheck = false; 13 - env.CGO_ENABLED = 0; 13 + CGO_ENABLED = 0; 14 14 }
+6 -7
nix/pkgs/knot-unwrapped.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 5 gitignoreSource, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "knot"; 10 9 version = "0.1.0"; 11 10 src = gitignoreSource ../..; 11 + inherit modules; 12 12 13 13 doCheck = false; 14 14 15 15 subPackages = ["cmd/knot"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 16 + tags = ["libsqlite3"]; 18 17 19 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 20 + CGO_ENABLED = 1; 22 21 }
+6 -7
nix/pkgs/spindle.nix
··· 1 1 { 2 - buildGoModule, 3 - stdenv, 2 + buildGoApplication, 3 + modules, 4 4 sqlite-lib, 5 - goModHash, 6 5 gitignoreSource, 7 6 }: 8 - buildGoModule { 7 + buildGoApplication { 9 8 pname = "spindle"; 10 9 version = "0.1.0"; 11 10 src = gitignoreSource ../..; 11 + inherit modules; 12 12 13 13 doCheck = false; 14 14 15 15 subPackages = ["cmd/spindle"]; 16 - vendorHash = goModHash; 17 - tags = "libsqlite3"; 16 + tags = ["libsqlite3"]; 18 17 19 18 env.CGO_CFLAGS = "-I ${sqlite-lib}/include "; 20 19 env.CGO_LDFLAGS = "-L ${sqlite-lib}/lib"; 21 - env.CGO_ENABLED = 1; 20 + CGO_ENABLED = 1; 22 21 }
+25
patchutil/interdiff.go
··· 5 5 "strings" 6 6 7 7 "github.com/bluekeyes/go-gitdiff/gitdiff" 8 + "tangled.sh/tangled.sh/core/types" 8 9 ) 9 10 10 11 type InterdiffResult struct { ··· 33 34 *gitdiff.File 34 35 Name string 35 36 Status InterdiffFileStatus 37 + } 38 + 39 + func (s *InterdiffFile) Split() *types.SplitDiff { 40 + fragments := make([]types.SplitFragment, len(s.TextFragments)) 41 + 42 + for i, fragment := range s.TextFragments { 43 + leftLines, rightLines := types.SeparateLines(fragment) 44 + 45 + fragments[i] = types.SplitFragment{ 46 + Header: fragment.Header(), 47 + LeftLines: leftLines, 48 + RightLines: rightLines, 49 + } 50 + } 51 + 52 + return &types.SplitDiff{ 53 + Name: s.Id(), 54 + TextFragments: fragments, 55 + } 56 + } 57 + 58 + // used by html elements as a unique ID for hrefs 59 + func (s *InterdiffFile) Id() string { 60 + return s.Name 36 61 } 37 62 38 63 func (s *InterdiffFile) String() string {
+26
types/diff.go
··· 5 5 "github.com/go-git/go-git/v5/plumbing/object" 6 6 ) 7 7 8 + type DiffOpts struct { 9 + Split bool `json:"split"` 10 + } 11 + 8 12 type TextFragment struct { 9 13 Header string `json:"comment"` 10 14 Lines []gitdiff.Line `json:"lines"` ··· 77 81 78 82 return files 79 83 } 84 + 85 + // used by html elements as a unique ID for hrefs 86 + func (d *Diff) Id() string { 87 + return d.Name.New 88 + } 89 + 90 + func (d *Diff) Split() *SplitDiff { 91 + fragments := make([]SplitFragment, len(d.TextFragments)) 92 + for i, fragment := range d.TextFragments { 93 + leftLines, rightLines := SeparateLines(&fragment) 94 + fragments[i] = SplitFragment{ 95 + Header: fragment.Header(), 96 + LeftLines: leftLines, 97 + RightLines: rightLines, 98 + } 99 + } 100 + 101 + return &SplitDiff{ 102 + Name: d.Id(), 103 + TextFragments: fragments, 104 + } 105 + }
+131
types/split.go
··· 1 + package types 2 + 3 + import ( 4 + "github.com/bluekeyes/go-gitdiff/gitdiff" 5 + ) 6 + 7 + type SplitLine struct { 8 + LineNumber int `json:"line_number,omitempty"` 9 + Content string `json:"content"` 10 + Op gitdiff.LineOp `json:"op"` 11 + IsEmpty bool `json:"is_empty"` 12 + } 13 + 14 + type SplitFragment struct { 15 + Header string `json:"header"` 16 + LeftLines []SplitLine `json:"left_lines"` 17 + RightLines []SplitLine `json:"right_lines"` 18 + } 19 + 20 + type SplitDiff struct { 21 + Name string `json:"name"` 22 + TextFragments []SplitFragment `json:"fragments"` 23 + } 24 + 25 + // used by html elements as a unique ID for hrefs 26 + func (d *SplitDiff) Id() string { 27 + return d.Name 28 + } 29 + 30 + // separate lines into left and right, this includes additional logic to 31 + // group consecutive runs of additions and deletions in order to align them 32 + // properly in the final output 33 + // 34 + // TODO: move all diff stuff to a single package, we are spread across patchutil and types right now 35 + func SeparateLines(fragment *gitdiff.TextFragment) ([]SplitLine, []SplitLine) { 36 + lines := fragment.Lines 37 + var leftLines, rightLines []SplitLine 38 + oldLineNum := fragment.OldPosition 39 + newLineNum := fragment.NewPosition 40 + 41 + // process deletions and additions in groups for better alignment 42 + i := 0 43 + for i < len(lines) { 44 + line := lines[i] 45 + 46 + switch line.Op { 47 + case gitdiff.OpContext: 48 + leftLines = append(leftLines, SplitLine{ 49 + LineNumber: int(oldLineNum), 50 + Content: line.Line, 51 + Op: gitdiff.OpContext, 52 + IsEmpty: false, 53 + }) 54 + rightLines = append(rightLines, SplitLine{ 55 + LineNumber: int(newLineNum), 56 + Content: line.Line, 57 + Op: gitdiff.OpContext, 58 + IsEmpty: false, 59 + }) 60 + oldLineNum++ 61 + newLineNum++ 62 + i++ 63 + 64 + case gitdiff.OpDelete: 65 + deletionCount := 0 66 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpDelete; j++ { 67 + leftLines = append(leftLines, SplitLine{ 68 + LineNumber: int(oldLineNum), 69 + Content: lines[j].Line, 70 + Op: gitdiff.OpDelete, 71 + IsEmpty: false, 72 + }) 73 + oldLineNum++ 74 + deletionCount++ 75 + } 76 + i += deletionCount 77 + 78 + additionCount := 0 79 + for j := i; j < len(lines) && lines[j].Op == gitdiff.OpAdd; j++ { 80 + rightLines = append(rightLines, SplitLine{ 81 + LineNumber: int(newLineNum), 82 + Content: lines[j].Line, 83 + Op: gitdiff.OpAdd, 84 + IsEmpty: false, 85 + }) 86 + newLineNum++ 87 + additionCount++ 88 + } 89 + i += additionCount 90 + 91 + // add empty lines to balance the sides 92 + if deletionCount > additionCount { 93 + // more deletions than additions - pad right side 94 + for k := 0; k < deletionCount-additionCount; k++ { 95 + rightLines = append(rightLines, SplitLine{ 96 + Content: "", 97 + Op: gitdiff.OpContext, 98 + IsEmpty: true, 99 + }) 100 + } 101 + } else if additionCount > deletionCount { 102 + // more additions than deletions - pad left side 103 + for k := 0; k < additionCount-deletionCount; k++ { 104 + leftLines = append(leftLines, SplitLine{ 105 + Content: "", 106 + Op: gitdiff.OpContext, 107 + IsEmpty: true, 108 + }) 109 + } 110 + } 111 + 112 + case gitdiff.OpAdd: 113 + // standalone addition (not preceded by deletion) 114 + leftLines = append(leftLines, SplitLine{ 115 + Content: "", 116 + Op: gitdiff.OpContext, 117 + IsEmpty: true, 118 + }) 119 + rightLines = append(rightLines, SplitLine{ 120 + LineNumber: int(newLineNum), 121 + Content: line.Line, 122 + Op: gitdiff.OpAdd, 123 + IsEmpty: false, 124 + }) 125 + newLineNum++ 126 + i++ 127 + } 128 + } 129 + 130 + return leftLines, rightLines 131 + }