diff --git a/.env.example b/.env.example index 24d20acc3..2db9c7a8b 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,14 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # SPROUT_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Git (NIP-34 bare repositories) +# ----------------------------------------------------------------------------- +# Root directory for bare git repos. Repos are stored at +# {path}/{owner_hex}/{repo_id}.git/. Default: ./repos (relative to CWD). +# Set an absolute path to keep repos stable across worktrees. +# SPROUT_GIT_REPO_PATH=./repos + # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) # ----------------------------------------------------------------------------- diff --git a/.gitignore b/.gitignore index 08f94e246..18c8f7d5f 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,11 @@ node_modules/ # sqlx offline query data (generated, not portable) .sqlx/ +# SQLite database (created by relay in CWD) +sprout.db +sprout.db-wal +sprout.db-shm + # Docker volumes (if mounted locally) mysql-data/ typesense-data/ diff --git a/package.json b/package.json index 09cdd186a..8589754ba 100644 --- a/package.json +++ b/package.json @@ -7,5 +7,8 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.6" + }, + "dependencies": { + "@tailwindcss/typography": "^0.5.19" } } diff --git a/patches/isomorphic-git.patch b/patches/isomorphic-git.patch new file mode 100644 index 000000000..82195b6f5 --- /dev/null +++ b/patches/isomorphic-git.patch @@ -0,0 +1,21 @@ +diff --git a/package.json b/package.json +index 3111ab7abfd119fac72b374a0c111e236af10259..bb2e968f8eff5ef09bb1dd6316dfa4a2e3daeabe 100644 +--- a/package.json ++++ b/package.json +@@ -8,8 +8,14 @@ + "module": "./index.js", + "exports": { + ".": { +- "types": "./index.d.cts", +- "default": "./index.cjs" ++ "import": { ++ "types": "./index.d.ts", ++ "default": "./index.js" ++ }, ++ "require": { ++ "types": "./index.d.cts", ++ "default": "./index.cjs" ++ } + }, + "./http/node": { + "import": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c0249815..c45bd1db8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,9 +4,18 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + isomorphic-git: + hash: 5b43d4f88847fa3640b2babcef47638b012883aff16efacd3818d29bfa690cb1 + path: patches/isomorphic-git.patch + importers: .: + dependencies: + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.4)) devDependencies: '@biomejs/biome': specifier: ^2.4.6 @@ -161,7 +170,7 @@ importers: version: 1.59.1 '@tanstack/router-plugin': specifier: ^1.167.12 - version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)) + version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)) '@tanstack/virtual-file-routes': specifier: ^1.161.7 version: 1.161.7 @@ -176,7 +185,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)) autoprefixer: specifier: ^10.4.27 version: 10.5.0(postcss@8.5.14) @@ -197,28 +206,40 @@ importers: version: 5.9.3 vite: specifier: ^7.0.4 - version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4) + version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4) web: dependencies: + '@isomorphic-git/lightning-fs': + specifier: ^4.6.2 + version: 4.6.2 '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.5) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.4)) '@tanstack/react-query': specifier: ^5.90.21 version: 5.100.9(react@19.2.5) '@tanstack/react-router': specifier: ^1.168.10 version: 1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + buffer: + specifier: ^6.0.3 + version: 6.0.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 clsx: specifier: ^2.1.1 version: 2.1.1 + isomorphic-git: + specifier: ^1.37.6 + version: 1.37.6(patch_hash=5b43d4f88847fa3640b2babcef47638b012883aff16efacd3818d29bfa690cb1) lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) @@ -231,6 +252,12 @@ importers: react-dom: specifier: ^19.1.0 version: 19.2.5(react@19.2.5) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.5) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 sonner: specifier: ^2.0.7 version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -243,7 +270,7 @@ importers: version: 1.59.1 '@tanstack/router-plugin': specifier: ^1.167.12 - version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)) + version: 1.167.32(@tanstack/react-router@1.169.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)) '@tanstack/virtual-file-routes': specifier: ^1.161.7 version: 1.161.7 @@ -255,7 +282,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.6.0 - version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4)) + version: 4.7.0(vite@7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4)) autoprefixer: specifier: ^10.4.27 version: 10.5.0(postcss@8.5.14) @@ -273,7 +300,7 @@ importers: version: 5.9.3 vite: specifier: ^7.0.4 - version: 7.3.2(@types/node@25.6.0)(jiti@2.6.1)(yaml@2.8.4) + version: 7.3.2(@types/node@25.6.0)(jiti@1.21.7)(yaml@2.8.4) packages: @@ -613,6 +640,13 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@isomorphic-git/idb-keyval@3.3.2': + resolution: {integrity: sha512-r8/AdpiS0/WJCNR/t/gsgL+M8NMVj/ek7s60uz3LmpCaTF2mEVlZJlB01ZzalgYzRLXwSPC92o+pdzjM7PN/pA==} + + '@isomorphic-git/lightning-fs@4.6.2': + resolution: {integrity: sha512-RS/oa1UBnoUFe56bsjOEgoUUReYKQzYUlQnbERRRNv9s9KmjyWuuylPV+YgsWirR2oONKaipWYMebVQ8SAe55Q==} + hasBin: true + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1314,6 +1348,11 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/history@1.161.6': resolution: {integrity: sha512-NaOGLRrddszbQj9upGat6HG/4TKvXLvu+osAIgfxPYA+eIvYKv8GKDJOrY2D3/U9MRnKfMWD7bU4jeD4xmqyIg==} engines: {node: '>=20.19'} @@ -1707,6 +1746,10 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + ansis@4.2.0: resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} engines: {node: '>=14'} @@ -1728,6 +1771,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + autoprefixer@10.5.0: resolution: {integrity: sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==} engines: {node: ^10 || ^12 || >=14} @@ -1735,12 +1781,19 @@ packages: peerDependencies: postcss: ^8.1.0 + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.27: resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} engines: {node: '>=6.0.0'} @@ -1759,6 +1812,21 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -1794,6 +1862,9 @@ packages: classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + clean-git-ref@2.0.1: + resolution: {integrity: sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1811,6 +1882,11 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1831,6 +1907,14 @@ packages: decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1847,6 +1931,9 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + diff3@0.0.3: + resolution: {integrity: sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==} + diff@8.0.4: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} @@ -1854,6 +1941,10 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + electron-to-chromium@1.5.349: resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} @@ -1864,10 +1955,18 @@ packages: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + es-errors@1.3.0: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -1884,6 +1983,14 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1895,6 +2002,9 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-text-encoding@1.0.6: + resolution: {integrity: sha512-VhXlQgj9ioXCqGstD37E/HBeqEGV/qOD/kmbVG8h5xKBYvM1L3lR1Zn4555cQ8GkYbJa8aJSipLPndE1k6zK2w==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1911,6 +2021,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -1945,10 +2059,18 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + gitdiff-parser@0.3.1: resolution: {integrity: sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==} @@ -1960,6 +2082,21 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.3: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} @@ -1979,6 +2116,16 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1992,6 +2139,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -2018,10 +2169,25 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.1.39: resolution: {integrity: sha512-obH0yYahGXdzNxo+djmHhBYThUKDkz565cxkIlt2L9hXfv1NlaLKoDBHo6KxXsYrIXx2RK3x5vY36CfZcobxEw==} engines: {node: '>=18'} + isomorphic-git@1.37.6: + resolution: {integrity: sha512-qr1NFCPsVTZ6YGqTXw0CzamnsHyH9QQ1OTEfeXIweSljRUMzuHFCJdUn0wc6OcjtTDns6knxjPb7N6LmJeftOA==} + engines: {node: '>=14.17'} + hasBin: true + + isomorphic-textencoder@1.0.1: + resolution: {integrity: sha512-676hESgHullDdHDsj469hr+7t3i/neBKU9J7q1T4RHaWwLAsaQnywC0D1dIUId0YZ+JtVrShzuBk1soo0+GVcQ==} + jdenticon@3.3.0: resolution: {integrity: sha512-DhuBRNRIybGPeAjMjdHbkIfiwZCCmf8ggu7C49jhp6aJ7DYsZfudnvnTY5/1vgUhrGA7JaDAx1WevnpjCPvaGg==} engines: {node: '>=6.4.0'} @@ -2048,6 +2214,12 @@ packages: engines: {node: '>=6'} hasBin: true + just-debounce-it@1.1.0: + resolution: {integrity: sha512-87Nnc0qZKgBZuhFZjYVjSraic0x7zwjhaTMrCKlj0QYKH6lh0KbFzVnfu6LHan03NO7J8ygjeBeD0epejn5Zcg==} + + just-once@1.1.0: + resolution: {integrity: sha512-+rZVpl+6VyTilK7vB/svlMPil4pxqIJZkbnN7DKZTOzyXfun6ZiFeq2Pk4EtCEHZ0VU4EkdFzG8ZK5F3PErcDw==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2092,6 +2264,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -2235,6 +2411,16 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minimisted@2.0.1: + resolution: {integrity: sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==} + motion-dom@12.38.0: resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} @@ -2292,6 +2478,9 @@ packages: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + oniguruma-parser@0.12.2: resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} @@ -2301,6 +2490,9 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -2325,6 +2517,10 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -2339,6 +2535,10 @@ packages: engines: {node: '>=18'} hasBin: true + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2375,6 +2575,10 @@ packages: peerDependencies: postcss: ^8.2.14 + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -2391,6 +2595,10 @@ packages: engines: {node: '>=14'} hasBin: true + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -2502,6 +2710,10 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2550,6 +2762,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2567,6 +2782,15 @@ packages: resolution: {integrity: sha512-BXe0x4buEeYiIKaRUnth1WqCILQ3k4O67KP/B4pC3pVz0Mv2c96ngA9QDREUYxWY1sb2RZVRqwI9RcpVMyHCVw==} engines: {node: '>=10'} + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + sha.js@2.4.12: + resolution: {integrity: sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==} + engines: {node: '>= 0.10'} + hasBin: true + shallow-equal@3.1.0: resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==} @@ -2574,6 +2798,12 @@ packages: resolution: {integrity: sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ==} engines: {node: '>=20'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: @@ -2587,6 +2817,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2634,6 +2867,10 @@ packages: peerDependencies: '@tiptap/core': ^3.0.1 + to-buffer@1.2.2: + resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} + engines: {node: '>= 0.4'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2650,6 +2887,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -2772,6 +3013,13 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + wrappy@1.0.2: + resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3049,6 +3297,15 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@isomorphic-git/idb-keyval@3.3.2': {} + + '@isomorphic-git/lightning-fs@4.6.2': + dependencies: + '@isomorphic-git/idb-keyval': 3.3.2 + isomorphic-textencoder: 1.0.1 + just-debounce-it: 1.1.0 + just-once: 1.1.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3678,6 +3935,11 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(yaml@2.8.4))': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.19(yaml@2.8.4) + '@tanstack/history@1.161.6': {} '@tanstack/query-core@5.100.9': {} @@ -4128,6 +4390,10 @@ snapshots: transitivePeerDependencies: - supports-color + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + ansis@4.2.0: {} any-promise@1.3.0: {} @@ -4145,6 +4411,8 @@ snapshots: dependencies: tslib: 2.8.1 + async-lock@1.4.1: {} + autoprefixer@10.5.0(postcss@8.5.14): dependencies: browserslist: 4.28.2 @@ -4154,6 +4422,10 @@ snapshots: postcss: 8.5.14 postcss-value-parser: 4.2.0 + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + babel-dead-code-elimination@1.0.12: dependencies: '@babel/core': 7.29.0 @@ -4165,6 +4437,8 @@ snapshots: bail@2.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.27: {} binary-extensions@2.3.0: {} @@ -4181,6 +4455,28 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + camelcase-css@2.0.1: {} caniuse-lite@1.0.30001791: {} @@ -4217,6 +4513,8 @@ snapshots: classnames@2.5.1: {} + clean-git-ref@2.0.1: {} + clsx@2.1.1: {} comma-separated-tokens@2.0.3: {} @@ -4227,6 +4525,8 @@ snapshots: cookie-es@3.1.1: {} + crc-32@1.2.2: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -4239,6 +4539,16 @@ snapshots: dependencies: character-entities: 2.0.2 + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dequal@2.0.3: {} detect-node-es@1.1.0: {} @@ -4251,18 +4561,32 @@ snapshots: diff-match-patch@1.0.5: {} + diff3@0.0.3: {} + diff@8.0.4: {} dlv@1.1.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + electron-to-chromium@1.5.349: {} emoji-mart@5.6.0: {} entities@4.5.0: {} + es-define-property@1.0.1: {} + es-errors@1.3.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -4298,6 +4622,10 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + event-target-shim@5.0.1: {} + + events@3.3.0: {} + extend@3.0.2: {} fast-equals@5.4.0: {} @@ -4310,6 +4638,8 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-text-encoding@1.0.6: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4322,6 +4652,10 @@ snapshots: dependencies: to-regex-range: 5.0.1 + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + fraction.js@5.3.4: {} framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): @@ -4343,8 +4677,26 @@ snapshots: gensync@1.0.0-beta.2: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + gitdiff-parser@0.3.1: {} glob-parent@5.1.2: @@ -4355,6 +4707,18 @@ snapshots: dependencies: is-glob: 4.0.3 + gopd@1.2.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -4401,6 +4765,12 @@ snapshots: html-void-elements@3.0.0: {} + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + inherits@2.0.4: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -4414,6 +4784,8 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-callable@1.2.7: {} + is-core-module@2.16.1: dependencies: hasown: 2.0.3 @@ -4432,8 +4804,32 @@ snapshots: is-plain-obj@4.1.0: {} + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + isarray@2.0.5: {} + isbot@5.1.39: {} + isomorphic-git@1.37.6(patch_hash=5b43d4f88847fa3640b2babcef47638b012883aff16efacd3818d29bfa690cb1): + dependencies: + async-lock: 1.4.1 + clean-git-ref: 2.0.1 + crc-32: 1.2.2 + diff3: 0.0.3 + ignore: 5.3.2 + minimisted: 2.0.1 + pako: 1.0.11 + pify: 4.0.1 + readable-stream: 4.7.0 + sha.js: 2.4.12 + simple-get: 4.0.1 + + isomorphic-textencoder@1.0.1: + dependencies: + fast-text-encoding: 1.0.6 + jdenticon@3.3.0: dependencies: canvas-renderer: 2.2.1 @@ -4448,6 +4844,10 @@ snapshots: json5@2.2.3: {} + just-debounce-it@1.1.0: {} + + just-once@1.1.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -4491,6 +4891,8 @@ snapshots: markdown-table@3.0.4: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -4849,6 +5251,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + minimisted@2.0.1: + dependencies: + minimist: 1.2.8 + motion-dom@12.38.0: dependencies: motion-utils: 12.36.0 @@ -4895,6 +5305,10 @@ snapshots: object-hash@3.0.0: {} + once@1.4.0: + dependencies: + wrappy: 1.0.2 + oniguruma-parser@0.12.2: {} oniguruma-to-es@4.3.6: @@ -4905,6 +5319,8 @@ snapshots: orderedmap@2.1.1: {} + pako@1.0.11: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -4927,6 +5343,8 @@ snapshots: pify@2.3.0: {} + pify@4.0.1: {} + pirates@4.0.7: {} playwright-core@1.59.1: {} @@ -4937,6 +5355,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} + postcss-import@15.1.0(postcss@8.5.14): dependencies: postcss: 8.5.14 @@ -4962,6 +5382,11 @@ snapshots: postcss: 8.5.14 postcss-selector-parser: 6.1.2 + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -4977,6 +5402,8 @@ snapshots: prettier@3.8.3: {} + process@0.11.10: {} + property-information@7.1.0: {} prosemirror-changeset@2.4.1: @@ -5130,6 +5557,14 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -5230,6 +5665,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + safe-buffer@5.2.1: {} + scheduler@0.27.0: {} semver@6.3.1: {} @@ -5240,6 +5677,21 @@ snapshots: seroval@1.5.3: {} + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + sha.js@2.4.12: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + to-buffer: 1.2.2 + shallow-equal@3.1.0: {} shiki@4.0.2: @@ -5253,6 +5705,14 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: react: 19.2.5 @@ -5262,6 +5722,10 @@ snapshots: space-separated-tokens@2.0.2: {} + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 @@ -5342,6 +5806,12 @@ snapshots: markdown-it-task-lists: 2.1.1 prosemirror-markdown: 1.13.4 + to-buffer@1.2.2: + dependencies: + isarray: 2.0.5 + safe-buffer: 5.2.1 + typed-array-buffer: 1.0.3 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -5354,6 +5824,12 @@ snapshots: tslib@2.8.1: {} + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + typescript@5.9.3: {} uc.micro@2.1.0: {} @@ -5472,6 +5948,18 @@ snapshots: webpack-virtual-modules@0.6.2: {} + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + wrappy@1.0.2: {} + yallist@3.1.1: {} yaml@2.8.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 804cc358b..99ed8a6b3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,5 @@ packages: - "desktop" - "web" +patchedDependencies: + isomorphic-git: patches/isomorphic-git.patch diff --git a/web/package.json b/web/package.json index 35dbd5819..9c7f5a23f 100644 --- a/web/package.json +++ b/web/package.json @@ -16,16 +16,22 @@ "test:e2e:smoke": "pnpm build && playwright test --project=smoke" }, "dependencies": { + "@isomorphic-git/lightning-fs": "^4.6.2", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.168.10", + "buffer": "^6.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isomorphic-git": "^1.37.6", "lucide-react": "^0.577.0", "nostr-tools": "^2.23.3", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0" }, diff --git a/web/src/features/repos/git-client.ts b/web/src/features/repos/git-client.ts new file mode 100644 index 000000000..0e808b56f --- /dev/null +++ b/web/src/features/repos/git-client.ts @@ -0,0 +1,219 @@ +/** + * isomorphic-git wrapper for in-browser repo browsing. + * + * Uses LightningFS (IndexedDB-backed) for persistence and NIP-98 auth + * for the relay's smart HTTP git transport. + */ + +// isomorphic-git expects a global Buffer (Node API) for pack-file parsing, +// tree serialization, etc. The `buffer` package (feross/buffer) is the +// standard browser polyfill — we install it before any git imports run. +import { Buffer } from "buffer"; +if (typeof (globalThis as Record).Buffer === "undefined") { + (globalThis as Record).Buffer = Buffer; +} + +import LightningFS from "@isomorphic-git/lightning-fs"; +import { + clone, + fetch, + log, + readBlob, + readTree, + resolveRef, +} from "isomorphic-git"; +import http from "isomorphic-git/http/web"; +import { makeNip98AuthHeader } from "@/shared/lib/nip98"; +import { relayHttpBaseUrl } from "@/shared/lib/relay-url"; + +/** Get a repo-specific LightningFS instance backed by IndexedDB. */ +export function getFs(owner: string, repoName: string): LightningFS { + return new LightningFS(`sprout-git-${owner}-${repoName}`); +} + +/** Working directory inside the virtual FS. */ +export function getDir(owner: string, repoName: string): string { + return `/${owner}/${repoName}`; +} + +function repoGitUrl(owner: string, repoName: string): string { + return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`; +} + +/** + * The NIP-98 `u` tag URL — must match what transport.rs expects after + * stripping `/info/refs`, `/git-upload-pack`, `/git-receive-pack`. + * That means the full path including `.git`. + */ +function repoAuthUrl(owner: string, repoName: string): string { + return `${relayHttpBaseUrl()}/git/${owner}/${repoName}.git`; +} + +function authHeaders(owner: string, repoName: string): Record { + return { + Authorization: makeNip98AuthHeader(repoAuthUrl(owner, repoName), "GET"), + }; +} + +/** + * Ensure a shallow clone exists in IndexedDB. If it already exists, fetch + * the latest for the given ref. + */ +export async function ensureClone( + owner: string, + repoName: string, + ref: string, +): Promise<{ fs: LightningFS; dir: string }> { + const fs = getFs(owner, repoName); + const dir = getDir(owner, repoName); + const url = repoGitUrl(owner, repoName); + const headers = authHeaders(owner, repoName); + + let exists = false; + try { + await fs.promises.stat(`${dir}/.git`); + exists = true; + } catch { + // repo not cloned yet + } + + if (exists) { + try { + await fetch({ + fs, + http, + dir, + url, + ref, + depth: 1, + singleBranch: true, + headers, + }); + } catch { + // fetch may fail if ref hasn't changed — that's fine + } + } else { + await clone({ + fs, + http, + dir, + url, + ref, + depth: 1, + singleBranch: true, + noTags: true, + headers, + }); + } + + return { fs, dir }; +} + +export interface TreeEntry { + name: string; + type: "blob" | "tree"; + mode: string; + oid: string; +} + +/** Read tree entries at a given path (or root if no filepath). */ +export async function readTreeEntries( + fs: LightningFS, + dir: string, + oid: string, + filepath?: string, +): Promise { + const result = await readTree({ fs, dir, oid, filepath }); + return result.tree.map((entry) => ({ + name: entry.path, + type: entry.type as "blob" | "tree", + mode: entry.mode, + oid: entry.oid, + })); +} + +export interface FileContent { + content: string; + isBinary: boolean; +} + +/** Read a blob and decode as text. Detects binary by checking for NUL bytes. */ +export async function readFileContent( + fs: LightningFS, + dir: string, + oid: string, + filepath: string, +): Promise { + const { blob } = await readBlob({ fs, dir, oid, filepath }); + + // Check first 512 bytes for NUL to detect binary + const checkLength = Math.min(blob.length, 512); + for (let i = 0; i < checkLength; i++) { + if (blob[i] === 0) { + return { content: "", isBinary: true }; + } + } + + const content = new TextDecoder().decode(blob); + return { content, isBinary: false }; +} + +export interface CommitInfo { + oid: string; + message: string; + author: { + name: string; + email: string; + timestamp: number; + }; +} + +/** Get recent commits for a ref. */ +export async function getCommitLog( + fs: LightningFS, + dir: string, + ref: string, + depth = 20, +): Promise { + const commits = await log({ fs, dir, ref, depth }); + return commits.map((c) => ({ + oid: c.oid, + message: c.commit.message, + author: { + name: c.commit.author.name, + email: c.commit.author.email, + timestamp: c.commit.author.timestamp, + }, + })); +} + +export interface ReadmeResult { + filename: string; + content: string; +} + +const README_PATTERNS = ["readme.md", "readme", "readme.rst", "readme.txt"]; + +/** Find and read a README file from the root tree. */ +export async function findReadme( + fs: LightningFS, + dir: string, + ref: string, +): Promise { + const oid = await resolveRef({ fs, dir, ref }); + const entries = await readTreeEntries(fs, dir, oid); + + for (const pattern of README_PATTERNS) { + const entry = entries.find( + (e) => e.type === "blob" && e.name.toLowerCase() === pattern, + ); + if (entry) { + const file = await readFileContent(fs, dir, oid, entry.name); + if (!file.isBinary) { + return { filename: entry.name, content: file.content }; + } + } + } + + return null; +} diff --git a/web/src/features/repos/ui/RepoCommitsSection.tsx b/web/src/features/repos/ui/RepoCommitsSection.tsx new file mode 100644 index 000000000..737f4a975 --- /dev/null +++ b/web/src/features/repos/ui/RepoCommitsSection.tsx @@ -0,0 +1,70 @@ +import { GitCommit } from "lucide-react"; +import { relativeTime } from "@/shared/lib/relative-time"; +import type { CommitInfo } from "../git-client"; + +function CommitRow({ commit }: { commit: CommitInfo }) { + const firstLine = commit.message.split("\n")[0]; + return ( +
+ +
+

{firstLine}

+

+ {commit.author.name} committed {relativeTime(commit.author.timestamp)} +

+
+ + {commit.oid.slice(0, 7)} + +
+ ); +} + +export function RepoCommitsSection({ + commits, + isLoading, +}: { + commits: CommitInfo[] | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+

+ + Recent commits +

+
+ {["sk-1", "sk-2", "sk-3"].map((key) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); + } + + if (!commits || commits.length === 0) return null; + + return ( +
+

+ + Recent commits +

+
+ {commits.map((commit) => ( + + ))} +
+
+ ); +} diff --git a/web/src/features/repos/ui/RepoDetailPage.tsx b/web/src/features/repos/ui/RepoDetailPage.tsx index 3b5142e60..52bd17fcc 100644 --- a/web/src/features/repos/ui/RepoDetailPage.tsx +++ b/web/src/features/repos/ui/RepoDetailPage.tsx @@ -4,6 +4,7 @@ import { Check, Copy, ExternalLink, + MessageSquare, Users, } from "lucide-react"; import { useEffect, useState } from "react"; @@ -12,28 +13,17 @@ import { toast } from "sonner"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; +import { relativeTime } from "@/shared/lib/relative-time"; +import { useRepoRefs } from "../use-repo-refs"; import { useRepo } from "../use-repos"; +import type { CommitInfo, ReadmeResult, TreeEntry } from "../git-client"; +import { useGitTree, useGitLog, useGitReadme } from "../use-git-browse"; import { ConnectButton } from "./ConnectButton"; import { PubkeyAvatar } from "./PubkeyAvatar"; - -function relativeTime(unix: number): string { - const now = Date.now(); - const diff = now - unix * 1000; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 30) { - const months = Math.floor(days / 30); - return months === 1 ? "1 month ago" : `${months} months ago`; - } - if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`; - if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`; - if (minutes > 0) - return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`; - return "just now"; -} +import { RepoRefsSection } from "./RepoRefsSection"; +import { RepoTreeSection } from "./RepoTreeSection"; +import { RepoCommitsSection } from "./RepoCommitsSection"; +import { RepoReadmeSection } from "./RepoReadmeSection"; function CopyableUrl({ url }: { url: string }) { const [copied, setCopied] = useState(false); @@ -70,14 +60,78 @@ function CopyableUrl({ url }: { url: string }) { function DetailSkeleton() { return ( -
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ ); +} + +type Tab = "code" | "commits"; + +function RepoTabs({ + treeEntries, + treeLoading, + commits, + commitsLoading, + readme, + readmeLoading, +}: { + treeEntries: TreeEntry[] | undefined; + treeLoading: boolean; + commits: CommitInfo[] | undefined; + commitsLoading: boolean; + readme: ReadmeResult | null | undefined; + readmeLoading: boolean; +}) { + const [tab, setTab] = useState("code"); + + return ( +
+ {/* Tab bar */} +
+ + +
+ + {/* Tab content */} + {tab === "code" && ( + <> + + + + )} + {tab === "commits" && ( + + )}
); } @@ -85,6 +139,35 @@ function DetailSkeleton() { export function RepoDetailPage() { const { repoId } = useParams({ from: "/repos/$repoId" }); const { data: repo, isLoading, error } = useRepo(repoId); + const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId); + + const defaultRef = refs?.head?.ref ?? "main"; + const owner = repo?.owner ?? ""; + const repoName = repo?.id ?? ""; + + const { + data: treeEntries, + isLoading: treeLoading, + error: treeError, + } = useGitTree(owner, repoName, defaultRef); + const { + data: commits, + isLoading: commitsLoading, + error: commitsError, + } = useGitLog(owner, repoName, defaultRef); + const { data: readme, isLoading: readmeLoading } = useGitReadme( + owner, + repoName, + defaultRef, + ); + + // Surface clone/browse errors — these are otherwise silent + const browseError = treeError || commitsError; + useEffect(() => { + if (browseError) { + console.error("[git-browse]", browseError); + } + }, [browseError]); useEffect(() => { if (error) { @@ -98,7 +181,34 @@ export function RepoDetailPage() { if (!repo) { return ( -
+
+
+ + + Back to repositories + +
+ +

Repository not found

+

+ This repository may have been removed or doesn't exist on this + relay. +

+
+
+
+ ); + } + + return ( +
+ {/* Main content */} +
+ {/* Back link */} Back to repositories -
- -

Repository not found

-

- This repository may have been removed or doesn't exist on this - relay. -

-
-
- ); - } - return ( -
- {/* Back link */} - - - Back to repositories - - - {/* Header */} -
-
- -

{repo.name}

- Public + {/* Mobile-only connect button */} +
+
- {repo.description && ( -

- {repo.description} + + {/* Header */} +

+
+ +

+ {repo.name} +

+ Public +
+ {repo.description && ( +

+ {repo.description} +

+ )} +

+ Updated {relativeTime(repo.createdAt)}

+
+ + {/* Refs & HEAD */} + + + {/* Clone/browse error banner */} + {browseError && ( +
+ Failed to load repository contents:{" "} + {browseError instanceof Error + ? browseError.message + : String(browseError)} +
)} -

- Updated {relativeTime(repo.createdAt)} -

-
- {/* Clone URLs */} - {repo.cloneUrls.length > 0 && ( -
-

Clone

-
- {repo.cloneUrls.map((url) => ( - - ))} + {/* Tabs */} + + + {/* Clone URLs */} + {repo.cloneUrls.length > 0 && ( +
+

Clone

+
+ {repo.cloneUrls.map((url) => ( + + ))} +
-
- )} + )} - {/* External link */} - {repo.webUrl && ( - - )} + {/* External link — validate scheme to prevent javascript: XSS */} + {(() => { + if (!repo.webUrl) return null; + let safe: string | null = null; + try { + safe = /^https?:/.test(new URL(repo.webUrl).protocol) + ? repo.webUrl + : null; + } catch { + safe = null; + } + if (!safe) return null; + return ( + + ); + })()} - {/* Owner & Contributors */} -
-

- - People -

-
- - {repo.contributors - .filter((c) => c !== repo.owner) - .map((c) => ( - - ))} -
+ {/* Channel link */} + {repo.channelId && ( + + )}
- {/* Open in Sprout CTA */} -
-

- Open this relay in the Sprout desktop app to push code and - collaborate. -

- -
+ {/* Sidebar */} +
); } diff --git a/web/src/features/repos/ui/RepoListItem.tsx b/web/src/features/repos/ui/RepoListItem.tsx index 341b54f63..723a34a40 100644 --- a/web/src/features/repos/ui/RepoListItem.tsx +++ b/web/src/features/repos/ui/RepoListItem.tsx @@ -3,6 +3,7 @@ import { Link } from "@tanstack/react-router"; import { Badge } from "@/shared/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { relativeTime } from "@/shared/lib/relative-time"; import type { Repo } from "../use-repos"; function truncateHex(hex: string): string { @@ -10,25 +11,6 @@ function truncateHex(hex: string): string { return `${hex.slice(0, 8)}...${hex.slice(-4)}`; } -function relativeTime(unix: number): string { - const now = Date.now(); - const diff = now - unix * 1000; - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 30) { - const months = Math.floor(days / 30); - return months === 1 ? "1 month ago" : `${months} months ago`; - } - if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`; - if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`; - if (minutes > 0) - return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`; - return "just now"; -} - export function RepoListItem({ repo }: { repo: Repo }) { return (
diff --git a/web/src/features/repos/ui/RepoReadmeSection.tsx b/web/src/features/repos/ui/RepoReadmeSection.tsx new file mode 100644 index 000000000..ee511f6a6 --- /dev/null +++ b/web/src/features/repos/ui/RepoReadmeSection.tsx @@ -0,0 +1,42 @@ +import { BookOpen } from "lucide-react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { ReadmeResult } from "../git-client"; + +export function RepoReadmeSection({ + readme, + isLoading, +}: { + readme: ReadmeResult | null | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+

+ + README +

+
+
+
+
+
+
+ ); + } + + if (!readme) return null; + + return ( +
+

+ + {readme.filename} +

+
+ {readme.content} +
+
+ ); +} diff --git a/web/src/features/repos/ui/RepoRefsSection.tsx b/web/src/features/repos/ui/RepoRefsSection.tsx new file mode 100644 index 000000000..11b38acd7 --- /dev/null +++ b/web/src/features/repos/ui/RepoRefsSection.tsx @@ -0,0 +1,54 @@ +import { GitBranch, Hash, Tag } from "lucide-react"; + +import { Badge } from "@/shared/ui/badge"; +import type { RepoRefs } from "../use-repo-refs"; + +export function RepoRefsSection({ + refs, + isLoading, +}: { + refs: RepoRefs | undefined; + isLoading: boolean; +}) { + if (isLoading) return null; + + const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0); + + return ( +
+ {hasRefs ? ( +
+ {refs.head && ( + <> +
+ + + {refs.head.ref} + + {refs.head.sha && ( + + + {refs.head.sha.slice(0, 7)} + + )} +
+ · + + )} + + + {refs.branches.length}{" "} + {refs.branches.length === 1 ? "branch" : "branches"} + + · + + + {refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"} + +
+ ) : ( +

No commits yet

+ )} +
+ ); +} diff --git a/web/src/features/repos/ui/RepoTreeSection.tsx b/web/src/features/repos/ui/RepoTreeSection.tsx new file mode 100644 index 000000000..86438ab6a --- /dev/null +++ b/web/src/features/repos/ui/RepoTreeSection.tsx @@ -0,0 +1,54 @@ +import { File, Folder } from "lucide-react"; +import type { TreeEntry } from "../git-client"; + +function TreeRow({ entry }: { entry: TreeEntry }) { + const isDir = entry.type === "tree"; + return ( +
+ {isDir ? ( + + ) : ( + + )} + {entry.name} +
+ ); +} + +export function RepoTreeSection({ + entries, + isLoading, +}: { + entries: TreeEntry[] | undefined; + isLoading: boolean; +}) { + if (isLoading) { + return ( +
+
+ {["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => ( +
+
+
+
+ ))} +
+
+ ); + } + + if (!entries || entries.length === 0) return null; + + return ( +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +} diff --git a/web/src/features/repos/use-git-browse.ts b/web/src/features/repos/use-git-browse.ts new file mode 100644 index 000000000..40c6f30c0 --- /dev/null +++ b/web/src/features/repos/use-git-browse.ts @@ -0,0 +1,109 @@ +/** + * React Query hooks for browsing git repos via isomorphic-git. + * + * All hooks depend on `useGitClone` which ensures the repo is shallow-cloned + * into IndexedDB before any reads happen. + */ + +import { useQuery } from "@tanstack/react-query"; +import { resolveRef } from "isomorphic-git"; +import { + ensureClone, + findReadme, + getCommitLog, + readFileContent, + readTreeEntries, +} from "./git-client"; + +/** + * Ensure the repo is cloned (or fetched) into IndexedDB. + * Other hooks depend on this to get `fs` and `dir`. + */ +export function useGitClone(owner: string, repoName: string, ref: string) { + return useQuery({ + queryKey: ["git-clone", owner, repoName, ref], + queryFn: () => ensureClone(owner, repoName, ref), + staleTime: 5 * 60_000, + enabled: !!owner && !!repoName && !!ref, + retry: false, + }); +} + +/** Read tree entries at a path (or root). Directories first, then files, alphabetical. */ +export function useGitTree( + owner: string, + repoName: string, + ref: string, + path?: string, +) { + const cloneQuery = useGitClone(owner, repoName, ref); + + return useQuery({ + queryKey: ["git-tree", owner, repoName, ref, path ?? ""], + queryFn: async () => { + const { fs, dir } = cloneQuery.data!; + const oid = await resolveRef({ fs, dir, ref }); + const entries = await readTreeEntries(fs, dir, oid, path || undefined); + + // Sort: directories first, then files, alphabetical within each group + return entries.sort((a, b) => { + if (a.type === "tree" && b.type !== "tree") return -1; + if (a.type !== "tree" && b.type === "tree") return 1; + return a.name.localeCompare(b.name); + }); + }, + enabled: !!cloneQuery.data, + staleTime: 5 * 60_000, + }); +} + +/** Get recent commits for the given ref. */ +export function useGitLog(owner: string, repoName: string, ref: string) { + const cloneQuery = useGitClone(owner, repoName, ref); + + return useQuery({ + queryKey: ["git-log", owner, repoName, ref], + queryFn: async () => { + const { fs, dir } = cloneQuery.data!; + return getCommitLog(fs, dir, ref); + }, + enabled: !!cloneQuery.data, + staleTime: 5 * 60_000, + }); +} + +/** Find and read the README from the repo root. */ +export function useGitReadme(owner: string, repoName: string, ref: string) { + const cloneQuery = useGitClone(owner, repoName, ref); + + return useQuery({ + queryKey: ["git-readme", owner, repoName, ref], + queryFn: async () => { + const { fs, dir } = cloneQuery.data!; + return findReadme(fs, dir, ref); + }, + enabled: !!cloneQuery.data, + staleTime: 5 * 60_000, + }); +} + +/** Read a single file's content. */ +export function useGitBlob( + owner: string, + repoName: string, + ref: string, + filepath: string, +) { + const cloneQuery = useGitClone(owner, repoName, ref); + + return useQuery({ + queryKey: ["git-blob", owner, repoName, ref, filepath], + queryFn: async () => { + const { fs, dir } = cloneQuery.data!; + const oid = await resolveRef({ fs, dir, ref }); + return readFileContent(fs, dir, oid, filepath); + }, + enabled: !!cloneQuery.data && !!filepath, + staleTime: 5 * 60_000, + }); +} diff --git a/web/src/features/repos/use-repo-refs.ts b/web/src/features/repos/use-repo-refs.ts new file mode 100644 index 000000000..dc21be279 --- /dev/null +++ b/web/src/features/repos/use-repo-refs.ts @@ -0,0 +1,68 @@ +import { useQuery } from "@tanstack/react-query"; +import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client"; +import { relayWsUrl } from "@/shared/lib/relay-url"; +import { dedup } from "./use-repos"; + +export interface RepoRefs { + branches: string[]; + tags: string[]; + head: { ref: string; sha: string } | null; +} + +function parseRefs(events: NostrEvent[]): RepoRefs { + const latest = dedup(events); + const branches: string[] = []; + const tags: string[] = []; + let head: RepoRefs["head"] = null; + + for (const event of latest) { + for (const tag of event.tags) { + const [name, value] = tag; + if (!name || !value) continue; + + if (name === "HEAD" && value.startsWith("ref: refs/heads/")) { + // HEAD points to a branch ref — find its SHA from a matching branch tag + const branchName = value.replace("ref: refs/heads/", ""); + head = { ref: branchName, sha: "" }; + } else if (name.startsWith("refs/heads/")) { + branches.push(name.replace("refs/heads/", "")); + } else if (name.startsWith("refs/tags/")) { + tags.push(name.replace("refs/tags/", "")); + } + } + } + + // Resolve HEAD SHA from the matching branch + if (head) { + for (const event of latest) { + for (const tag of event.tags) { + if (tag[0] === `refs/heads/${head.ref}` && tag[1]) { + head = { ref: head.ref, sha: tag[1] }; + break; + } + } + if (head.sha) break; + } + } + + return { branches, tags, head }; +} + +async function fetchRepoRefs(repoId: string): Promise { + // TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is + // exposed to the client. Without this, a user with ReposWrite permission + // could publish fake kind:30618 events with spoofed refs. + const events = await queryEvents(relayWsUrl(), { + kinds: [30618], + "#d": [repoId], + }); + return parseRefs(events); +} + +export function useRepoRefs(repoId: string) { + return useQuery({ + queryKey: ["repo-refs", repoId], + queryFn: () => fetchRepoRefs(repoId), + staleTime: 60_000, + }); +} diff --git a/web/src/features/repos/use-repos.ts b/web/src/features/repos/use-repos.ts index 6a647a215..d6f43df91 100644 --- a/web/src/features/repos/use-repos.ts +++ b/web/src/features/repos/use-repos.ts @@ -8,13 +8,14 @@ export interface Repo { description: string; cloneUrls: string[]; webUrl: string | null; + channelId: string | null; owner: string; contributors: string[]; createdAt: number; } /** Extract the first value for a given tag name from a Nostr event. */ -function getTag(event: NostrEvent, name: string): string | undefined { +export function getTag(event: NostrEvent, name: string): string | undefined { return event.tags.find((t) => t[0] === name)?.[1]; } @@ -29,6 +30,7 @@ function eventToRepo(event: NostrEvent): Repo { const description = getTag(event, "description") || event.content || ""; const cloneUrls = getAllTags(event, "clone"); const webUrl = getTag(event, "web") ?? null; + const channelId = getTag(event, "sprout-channel") ?? null; const contributors = getAllTags(event, "p"); const owner = event.pubkey; @@ -38,6 +40,7 @@ function eventToRepo(event: NostrEvent): Repo { description, cloneUrls, webUrl, + channelId, owner, contributors, createdAt: event.created_at, @@ -45,7 +48,7 @@ function eventToRepo(event: NostrEvent): Repo { } /** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */ -function dedup(events: NostrEvent[]): NostrEvent[] { +export function dedup(events: NostrEvent[]): NostrEvent[] { const best = new Map(); for (const e of events) { const d = getTag(e, "d") ?? ""; diff --git a/web/src/shared/lib/nip98.ts b/web/src/shared/lib/nip98.ts new file mode 100644 index 000000000..0e4da5df5 --- /dev/null +++ b/web/src/shared/lib/nip98.ts @@ -0,0 +1,33 @@ +/** + * NIP-98 HTTP Auth helper — signs a kind:27235 event for authenticating + * HTTP requests to the relay (used by isomorphic-git for smart HTTP transport). + */ + +import { finalizeEvent } from "nostr-tools/pure"; +import { getEphemeralKey } from "./nostr-client"; + +/** + * Build a NIP-98 Authorization header value. + * + * Creates a kind:27235 event with `u` and `method` tags, signs it with the + * session's ephemeral key, base64-encodes the JSON, and returns + * `"Nostr "`. + */ +export function makeNip98AuthHeader(url: string, method: string): string { + const event = finalizeEvent( + { + kind: 27235, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["u", url], + ["method", method], + ], + content: "", + }, + getEphemeralKey(), + ); + + const json = JSON.stringify(event); + const base64 = btoa(json); + return `Nostr ${base64}`; +} diff --git a/web/src/shared/lib/nostr-client.ts b/web/src/shared/lib/nostr-client.ts index d1df09a22..99e4718aa 100644 --- a/web/src/shared/lib/nostr-client.ts +++ b/web/src/shared/lib/nostr-client.ts @@ -32,7 +32,7 @@ const QUERY_TIMEOUT_MS = 10_000; /** Lazily-generated ephemeral keypair for NIP-42 AUTH. */ let _secretKey: Uint8Array | null = null; -function getEphemeralKey(): Uint8Array { +export function getEphemeralKey(): Uint8Array { if (!_secretKey) { _secretKey = generateSecretKey(); } diff --git a/web/src/shared/lib/relative-time.ts b/web/src/shared/lib/relative-time.ts new file mode 100644 index 000000000..2d8c91de0 --- /dev/null +++ b/web/src/shared/lib/relative-time.ts @@ -0,0 +1,18 @@ +/** Format a Unix timestamp (seconds) as a human-readable relative time string. */ +export function relativeTime(unix: number): string { + const diff = Date.now() - unix * 1000; + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 30) { + const months = Math.floor(days / 30); + return months === 1 ? "1 month ago" : `${months} months ago`; + } + if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`; + if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`; + if (minutes > 0) + return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`; + return "just now"; +} diff --git a/web/tailwind.config.js b/web/tailwind.config.js index 1ce6562a1..d3718f08b 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -1,4 +1,5 @@ import tailwindcssAnimate from "tailwindcss-animate"; +import tailwindcssTypography from "@tailwindcss/typography"; /** @type {import('tailwindcss').Config} */ export default { @@ -67,5 +68,5 @@ export default { }, }, }, - plugins: [tailwindcssAnimate], + plugins: [tailwindcssAnimate, tailwindcssTypography], };