Radish alpha
r
rad:z4D5UCArafTzTQpDZNQRuqswh3ury
Radicle desktop app
Radicle
Git
Add single issue view
Open rudolfs opened 1 year ago
23 files changed +1482 -68 8aae8e66 ecf420ab
modified .gitignore
@@ -5,6 +5,15 @@ node_modules/
src-tauri/target
src-tauri/gen/schemas

+
# KaTeX files
+
public/*.min.css
+
public/fonts/KaTeX_**.ttf
+
public/fonts/KaTeX_**.woff
+
public/fonts/KaTeX_**.woff2
+

+
# Twemoji Assets
+
public/twemoji/*.svg
+

# Editor directories and files
.vscode
.idea
modified index.html
@@ -63,6 +63,11 @@
    <link rel="stylesheet" type="text/css" href="/index.css" />
    <link rel="stylesheet" type="text/css" href="/typography.css" />
    <link rel="stylesheet" type="text/css" href="/colors.css" />
+
    <script type="module">
+
      // Make global 'Buffer' available to legacy modules.
+
      import { Buffer } from "buffer";
+
      window.Buffer = Buffer;
+
    </script>
  </head>

  <body>
modified package-lock.json
@@ -7,6 +7,7 @@
    "": {
      "name": "radicle-desktop",
      "version": "0.0.0",
+
      "hasInstallScript": true,
      "license": "MIT",
      "dependencies": {
        "@tauri-apps/api": "^2.0.0-beta.15",
@@ -16,25 +17,34 @@
      },
      "devDependencies": {
        "@eslint/js": "^9.11.1",
+
        "@radicle/gray-matter": "4.1.0",
        "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
        "@tauri-apps/cli": "^2.0.0-rc.1",
        "@tsconfig/svelte": "^5.0.4",
        "@types/dompurify": "^3.0.5",
        "@types/lodash": "^4.17.9",
        "@types/node": "^20.9.0",
+
        "@wooorm/starry-night": "^3.5.0",
        "baconjs": "^3.0.19",
        "bs58": "^6.0.0",
        "dompurify": "^3.1.6",
        "eslint": "^9.11.1",
        "eslint-config-prettier": "^9.1.0",
        "eslint-plugin-svelte": "^2.44.0",
+
        "hast-util-to-dom": "^4.0.0",
        "lodash": "^4.17.21",
+
        "marked": "^14.1.2",
+
        "marked-emoji": "^1.4.2",
+
        "marked-footnote": "^1.2.4",
+
        "marked-katex-extension": "^5.1.2",
+
        "marked-linkify-it": "^3.1.11",
        "prettier": "^3.3.3",
        "prettier-plugin-svelte": "^3.2.6",
        "svelte": "^5.0.0-next.243",
        "svelte-check": "^4.0.2",
        "svelte-eslint-parser": "^0.41.1",
        "tslib": "^2.7.0",
+
        "twemoji": "^14.0.2",
        "typescript": "^5.6.2",
        "typescript-eslint": "^8.7.0",
        "vite": "^5.4.7"
@@ -645,6 +655,21 @@
        "node": ">= 8"
      }
    },
+
    "node_modules/@radicle/gray-matter": {
+
      "version": "4.1.0",
+
      "resolved": "https://registry.npmjs.org/@radicle/gray-matter/-/gray-matter-4.1.0.tgz",
+
      "integrity": "sha512-Cbdz8QMzIuZXxeGpJtvnNiMYF4YTOJn1EDsEZ0GsgCVWVL96LGPZIu30/bEtw2U8p7anZuQNqa4ugqB+qsEjqw==",
+
      "dev": true,
+
      "dependencies": {
+
        "js-yaml": "^4.1.0",
+
        "kind-of": "^6.0.2",
+
        "section-matter": "^1.0.0",
+
        "strip-bom-string": "^1.0.0"
+
      },
+
      "engines": {
+
        "node": ">=6.0"
+
      }
+
    },
    "node_modules/@rollup/rollup-android-arm-eabi": {
      "version": "4.22.4",
      "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
@@ -1133,12 +1158,33 @@
      "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
      "dev": true
    },
+
    "node_modules/@types/hast": {
+
      "version": "3.0.4",
+
      "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz",
+
      "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/unist": "*"
+
      }
+
    },
    "node_modules/@types/json-schema": {
      "version": "7.0.15",
      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
      "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
      "dev": true
    },
+
    "node_modules/@types/katex": {
+
      "version": "0.16.7",
+
      "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
+
      "integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==",
+
      "dev": true
+
    },
+
    "node_modules/@types/linkify-it": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+
      "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+
      "dev": true
+
    },
    "node_modules/@types/lodash": {
      "version": "4.17.9",
      "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz",
@@ -1146,9 +1192,9 @@
      "dev": true
    },
    "node_modules/@types/node": {
-
      "version": "20.16.6",
-
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.6.tgz",
-
      "integrity": "sha512-T7PpxM/6yeDE+AdlVysT62BX6/bECZOmQAgiFg5NoBd5MQheZ3tzal7f1wvzfiEcmrcJNRi2zRr2nY2zF+0uqw==",
+
      "version": "20.16.7",
+
      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.7.tgz",
+
      "integrity": "sha512-QkDQjAY3gkvJNcZOWwzy3BN34RweT0OQ9zJyvLCU0kSK22dO2QYh/NHGfbEAYylPYzRB1/iXcojS79wOg5gFSw==",
      "dev": true,
      "dependencies": {
        "undici-types": "~6.19.2"
@@ -1160,6 +1206,12 @@
      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
      "dev": true
    },
+
    "node_modules/@types/unist": {
+
      "version": "3.0.3",
+
      "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+
      "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+
      "dev": true
+
    },
    "node_modules/@typescript-eslint/eslint-plugin": {
      "version": "8.7.0",
      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.7.0.tgz",
@@ -1378,6 +1430,22 @@
        "url": "https://opencollective.com/eslint"
      }
    },
+
    "node_modules/@wooorm/starry-night": {
+
      "version": "3.5.0",
+
      "resolved": "https://registry.npmjs.org/@wooorm/starry-night/-/starry-night-3.5.0.tgz",
+
      "integrity": "sha512-nYnfdeWS0ApqIFqr4ezLjr6pyYuqiG5Ywc2aJ4u1EY3qzf2oCTfLv7sMjEQSuSzPWUIH+a39eVbGUiRrFKZElA==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/hast": "^3.0.0",
+
        "import-meta-resolve": "^4.0.0",
+
        "vscode-oniguruma": "^2.0.0",
+
        "vscode-textmate": "^9.0.0"
+
      },
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/acorn": {
      "version": "8.12.1",
      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
@@ -1637,6 +1705,16 @@
      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
      "dev": true
    },
+
    "node_modules/commander": {
+
      "version": "8.3.0",
+
      "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
+
      "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
+
      "dev": true,
+
      "peer": true,
+
      "engines": {
+
        "node": ">= 12"
+
      }
+
    },
    "node_modules/concat-map": {
      "version": "0.0.1",
      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -1983,6 +2061,18 @@
        "node": ">=0.10.0"
      }
    },
+
    "node_modules/extend-shallow": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
+
      "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
+
      "dev": true,
+
      "dependencies": {
+
        "is-extendable": "^0.1.0"
+
      },
+
      "engines": {
+
        "node": ">=0.10.0"
+
      }
+
    },
    "node_modules/fast-deep-equal": {
      "version": "3.1.3",
      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2111,6 +2201,29 @@
      "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==",
      "dev": true
    },
+
    "node_modules/fs-extra": {
+
      "version": "8.1.0",
+
      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+
      "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+
      "dev": true,
+
      "dependencies": {
+
        "graceful-fs": "^4.2.0",
+
        "jsonfile": "^4.0.0",
+
        "universalify": "^0.1.0"
+
      },
+
      "engines": {
+
        "node": ">=6 <7 || >=8"
+
      }
+
    },
+
    "node_modules/fs-extra/node_modules/jsonfile": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+
      "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
+
      "dev": true,
+
      "optionalDependencies": {
+
        "graceful-fs": "^4.1.6"
+
      }
+
    },
    "node_modules/fsevents": {
      "version": "2.3.3",
      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2149,6 +2262,12 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
+
    "node_modules/graceful-fs": {
+
      "version": "4.2.11",
+
      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+
      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+
      "dev": true
+
    },
    "node_modules/graphemer": {
      "version": "1.4.0",
      "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2164,6 +2283,21 @@
        "node": ">=8"
      }
    },
+
    "node_modules/hast-util-to-dom": {
+
      "version": "4.0.0",
+
      "resolved": "https://registry.npmjs.org/hast-util-to-dom/-/hast-util-to-dom-4.0.0.tgz",
+
      "integrity": "sha512-oW7RScutPE58LfjuVUNvvH0+6rB89mAm/pkDqD3bdj9g6xKQlMcuW6yBmovbpDKkvYI2apPKmHZMtc9KiZTywA==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/hast": "^3.0.0",
+
        "property-information": "^6.0.0",
+
        "web-namespaces": "^2.0.0"
+
      },
+
      "funding": {
+
        "type": "opencollective",
+
        "url": "https://opencollective.com/unified"
+
      }
+
    },
    "node_modules/ignore": {
      "version": "5.3.2",
      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2189,6 +2323,16 @@
        "url": "https://github.com/sponsors/sindresorhus"
      }
    },
+
    "node_modules/import-meta-resolve": {
+
      "version": "4.1.0",
+
      "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz",
+
      "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==",
+
      "dev": true,
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/imurmurhash": {
      "version": "0.1.4",
      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
@@ -2210,6 +2354,15 @@
        "node": ">=8"
      }
    },
+
    "node_modules/is-extendable": {
+
      "version": "0.1.1",
+
      "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
+
      "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=0.10.0"
+
      }
+
    },
    "node_modules/is-extglob": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2294,6 +2447,35 @@
      "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
      "dev": true
    },
+
    "node_modules/jsonfile": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz",
+
      "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==",
+
      "dev": true,
+
      "dependencies": {
+
        "universalify": "^0.1.2"
+
      },
+
      "optionalDependencies": {
+
        "graceful-fs": "^4.1.6"
+
      }
+
    },
+
    "node_modules/katex": {
+
      "version": "0.16.11",
+
      "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
+
      "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
+
      "dev": true,
+
      "funding": [
+
        "https://opencollective.com/katex",
+
        "https://github.com/sponsors/katex"
+
      ],
+
      "peer": true,
+
      "dependencies": {
+
        "commander": "^8.3.0"
+
      },
+
      "bin": {
+
        "katex": "cli.js"
+
      }
+
    },
    "node_modules/keyv": {
      "version": "4.5.4",
      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -2303,6 +2485,15 @@
        "json-buffer": "3.0.1"
      }
    },
+
    "node_modules/kind-of": {
+
      "version": "6.0.3",
+
      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+
      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=0.10.0"
+
      }
+
    },
    "node_modules/kleur": {
      "version": "4.1.5",
      "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -2340,6 +2531,15 @@
        "node": ">=10"
      }
    },
+
    "node_modules/linkify-it": {
+
      "version": "5.0.0",
+
      "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+
      "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+
      "dev": true,
+
      "dependencies": {
+
        "uc.micro": "^2.0.0"
+
      }
+
    },
    "node_modules/locate-character": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -2382,6 +2582,62 @@
        "@jridgewell/sourcemap-codec": "^1.5.0"
      }
    },
+
    "node_modules/marked": {
+
      "version": "14.1.2",
+
      "resolved": "https://registry.npmjs.org/marked/-/marked-14.1.2.tgz",
+
      "integrity": "sha512-f3r0yqpz31VXiDB/wj9GaOB0a2PRLQl6vJmXiFrniNwjkKdvakqJRULhjFKJpxOchlCRiG5fcacoUZY5Xa6PEQ==",
+
      "dev": true,
+
      "bin": {
+
        "marked": "bin/marked.js"
+
      },
+
      "engines": {
+
        "node": ">= 18"
+
      }
+
    },
+
    "node_modules/marked-emoji": {
+
      "version": "1.4.2",
+
      "resolved": "https://registry.npmjs.org/marked-emoji/-/marked-emoji-1.4.2.tgz",
+
      "integrity": "sha512-2sP+bp2z76dwbILzQ7ijy2PyjjAJR3iAZCzaNGThD2UijFUBeidkn6MoCdX/j47tPIcWt9nwnjqRQPd01ZrfdA==",
+
      "dev": true,
+
      "peerDependencies": {
+
        "marked": ">=4 <15"
+
      }
+
    },
+
    "node_modules/marked-footnote": {
+
      "version": "1.2.4",
+
      "resolved": "https://registry.npmjs.org/marked-footnote/-/marked-footnote-1.2.4.tgz",
+
      "integrity": "sha512-DB2Kl+wFh6YwZd70qABMY6WUkG1UuyqoNTFoDfGyG79Pz24neYtLBkB+45a7o72V7gkfvbC3CGzIYFobxfMT1Q==",
+
      "dev": true,
+
      "peerDependencies": {
+
        "marked": ">=7.0.0"
+
      }
+
    },
+
    "node_modules/marked-katex-extension": {
+
      "version": "5.1.2",
+
      "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.2.tgz",
+
      "integrity": "sha512-jRtacvDAPULKBWArDno0IGpzzpUw12yb8OaEsv3dTlvcIr21+mF9kD+Bxo2m/ErX/2ZIml6zFVMnpxCpqx3stw==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/katex": "^0.16.7"
+
      },
+
      "peerDependencies": {
+
        "katex": ">=0.16 <0.17",
+
        "marked": ">=4 <15"
+
      }
+
    },
+
    "node_modules/marked-linkify-it": {
+
      "version": "3.1.11",
+
      "resolved": "https://registry.npmjs.org/marked-linkify-it/-/marked-linkify-it-3.1.11.tgz",
+
      "integrity": "sha512-xcrc9c4PMQdUoEO8dE6HLW80ShrolXBqqmJz1c9XdM5t/D0fzXXZ+FJOM4wqhs1AOfpjLipPQKmkcxA5cSFykw==",
+
      "dev": true,
+
      "dependencies": {
+
        "@types/linkify-it": "^5.0.0",
+
        "linkify-it": "^5.0.0"
+
      },
+
      "peerDependencies": {
+
        "marked": ">=4 <15"
+
      }
+
    },
    "node_modules/merge2": {
      "version": "1.4.1",
      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -2719,6 +2975,16 @@
        "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0"
      }
    },
+
    "node_modules/property-information": {
+
      "version": "6.5.0",
+
      "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz",
+
      "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==",
+
      "dev": true,
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/punycode": {
      "version": "2.3.1",
      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -2867,6 +3133,19 @@
        "node": ">=6"
      }
    },
+
    "node_modules/section-matter": {
+
      "version": "1.0.0",
+
      "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
+
      "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
+
      "dev": true,
+
      "dependencies": {
+
        "extend-shallow": "^2.0.1",
+
        "kind-of": "^6.0.0"
+
      },
+
      "engines": {
+
        "node": ">=4"
+
      }
+
    },
    "node_modules/semver": {
      "version": "7.6.3",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
@@ -2921,6 +3200,15 @@
        "node": ">=8"
      }
    },
+
    "node_modules/strip-bom-string": {
+
      "version": "1.0.0",
+
      "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
+
      "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">=0.10.0"
+
      }
+
    },
    "node_modules/strip-json-comments": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -2946,9 +3234,9 @@
      }
    },
    "node_modules/svelte": {
-
      "version": "5.0.0-next.258",
-
      "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.0.0-next.258.tgz",
-
      "integrity": "sha512-kkIXBIhYRaywZtch4bWfdVlE4uq1SYYHQo05qNlUJzyWemRyVA5jpoA3ezzXN5+ph6rf40IVRHE5jyr7ic6Hdg==",
+
      "version": "5.0.0-next.259",
+
      "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.0.0-next.259.tgz",
+
      "integrity": "sha512-trRFSjKD+11KbXerGmBT0Uc+ZSNUhxn0aQ02q9tjtig/FV24dpZlXmCrcZTZliOLS0P8JWjw6xaWgNheZZoYOg==",
      "dev": true,
      "dependencies": {
        "@ampproject/remapping": "^2.3.0",
@@ -3100,6 +3388,24 @@
      "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
      "dev": true
    },
+
    "node_modules/twemoji": {
+
      "version": "14.0.2",
+
      "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz",
+
      "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==",
+
      "dev": true,
+
      "dependencies": {
+
        "fs-extra": "^8.0.1",
+
        "jsonfile": "^5.0.0",
+
        "twemoji-parser": "14.0.0",
+
        "universalify": "^0.1.2"
+
      }
+
    },
+
    "node_modules/twemoji-parser": {
+
      "version": "14.0.0",
+
      "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz",
+
      "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==",
+
      "dev": true
+
    },
    "node_modules/type-check": {
      "version": "0.4.0",
      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -3148,12 +3454,27 @@
        }
      }
    },
+
    "node_modules/uc.micro": {
+
      "version": "2.1.0",
+
      "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+
      "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+
      "dev": true
+
    },
    "node_modules/undici-types": {
      "version": "6.19.8",
      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
      "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
      "dev": true
    },
+
    "node_modules/universalify": {
+
      "version": "0.1.2",
+
      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+
      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+
      "dev": true,
+
      "engines": {
+
        "node": ">= 4.0.0"
+
      }
+
    },
    "node_modules/uri-js": {
      "version": "4.4.1",
      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -3170,9 +3491,9 @@
      "dev": true
    },
    "node_modules/vite": {
-
      "version": "5.4.7",
-
      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.7.tgz",
-
      "integrity": "sha512-5l2zxqMEPVENgvzTuBpHer2awaetimj2BGkhBPdnwKbPNOlHsODU+oiazEZzLK7KhAnOrO+XGYJYn4ZlUhDtDQ==",
+
      "version": "5.4.8",
+
      "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz",
+
      "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==",
      "dev": true,
      "dependencies": {
        "esbuild": "^0.21.3",
@@ -3242,6 +3563,28 @@
        }
      }
    },
+
    "node_modules/vscode-oniguruma": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/vscode-oniguruma/-/vscode-oniguruma-2.0.1.tgz",
+
      "integrity": "sha512-poJU8iHIWnC3vgphJnrLZyI3YdqRlR27xzqDmpPXYzA93R4Gk8z7T6oqDzDoHjoikA2aS82crdXFkjELCdJsjQ==",
+
      "dev": true
+
    },
+
    "node_modules/vscode-textmate": {
+
      "version": "9.1.0",
+
      "resolved": "https://registry.npmjs.org/vscode-textmate/-/vscode-textmate-9.1.0.tgz",
+
      "integrity": "sha512-lxKSVp2DkFOx9RDAvpiYUrB9/KT1fAfi1aE8CBGstP8N7rLF+Seifj8kDA198X0mYj1CjQUC+81+nQf8CO0nVA==",
+
      "dev": true
+
    },
+
    "node_modules/web-namespaces": {
+
      "version": "2.0.1",
+
      "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
+
      "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
+
      "dev": true,
+
      "funding": {
+
        "type": "github",
+
        "url": "https://github.com/sponsors/wooorm"
+
      }
+
    },
    "node_modules/which": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
modified package.json
@@ -6,7 +6,8 @@
  "type": "module",
  "scripts": {
    "start": "vite",
-
    "build": "vite build",
+
    "build": "vite build && scripts/copy-katex-assets && scripts/install-twemoji-assets",
+
    "postinstall": "scripts/copy-katex-assets && scripts/install-twemoji-assets",
    "preview": "vite preview",
    "check": "scripts/check-js && scripts/check-rs",
    "check-js": "scripts/check-js",
@@ -27,25 +28,34 @@
  },
  "devDependencies": {
    "@eslint/js": "^9.11.1",
+
    "@radicle/gray-matter": "4.1.0",
    "@sveltejs/vite-plugin-svelte": "^4.0.0-next.6",
    "@tauri-apps/cli": "^2.0.0-rc.1",
    "@tsconfig/svelte": "^5.0.4",
    "@types/dompurify": "^3.0.5",
    "@types/lodash": "^4.17.9",
    "@types/node": "^20.9.0",
+
    "@wooorm/starry-night": "^3.5.0",
    "baconjs": "^3.0.19",
    "bs58": "^6.0.0",
    "dompurify": "^3.1.6",
    "eslint": "^9.11.1",
    "eslint-config-prettier": "^9.1.0",
    "eslint-plugin-svelte": "^2.44.0",
+
    "hast-util-to-dom": "^4.0.0",
    "lodash": "^4.17.21",
+
    "marked": "^14.1.2",
+
    "marked-emoji": "^1.4.2",
+
    "marked-footnote": "^1.2.4",
+
    "marked-katex-extension": "^5.1.2",
+
    "marked-linkify-it": "^3.1.11",
    "prettier": "^3.3.3",
    "prettier-plugin-svelte": "^3.2.6",
    "svelte": "^5.0.0-next.243",
    "svelte-check": "^4.0.2",
    "svelte-eslint-parser": "^0.41.1",
    "tslib": "^2.7.0",
+
    "twemoji": "^14.0.2",
    "typescript": "^5.6.2",
    "typescript-eslint": "^8.7.0",
    "vite": "^5.4.7"
added public/twemoji/.gitkeep
added scripts/copy-katex-assets
@@ -0,0 +1,7 @@
+
#!/usr/bin/env bash
+
set -Eeuo pipefail
+

+
echo "Copying katex assets into bundle directory"
+

+
cp -r node_modules/katex/dist/katex.min.css public/katex.min.css
+
cp -r node_modules/katex/dist/fonts/* public/fonts/
added scripts/install-twemoji-assets
@@ -0,0 +1,9 @@
+
#!/usr/bin/env bash
+
set -Eeou pipefail
+

+
version="$(node -e 'console.log(require("twemoji/package.json").version)')"
+

+
echo "Installing Twemoji SVG assets v${version}"
+

+
curl -sSL "https://github.com/twitter/twemoji/archive/refs/tags/v${version}.tar.gz" \
+
  | tar -x -z -C public/twemoji/ --strip-components=3 "twemoji-${version}/assets/svg"
modified src-tauri/src/commands/cobs.rs
@@ -4,7 +4,6 @@ use radicle::cob::ObjectId;
use radicle::git::Oid;
use radicle::identity::RepoId;
use radicle::issue::cache::Issues;
-
use radicle::issue::IssueId;
use radicle::patch::cache::Patches;

use crate::error::Error;
@@ -41,14 +40,14 @@ pub fn list_issues(
pub fn issues_by_id(
    ctx: tauri::State<AppState>,
    rid: RepoId,
-
    id: IssueId,
+
    id: Oid,
) -> Result<Option<cobs::Issue>, Error> {
    let (repo, _) = ctx.repo(rid)?;
    let issues = ctx.profile.issues(&repo)?;
-
    let issue = issues.get(&id)?;
+
    let issue = issues.get(&id.into())?;

    let aliases = &ctx.profile.aliases();
-
    let issue = issue.map(|issue| cobs::Issue::new(id, issue, aliases));
+
    let issue = issue.map(|issue| cobs::Issue::new(id.into(), issue, aliases));

    Ok::<_, Error>(issue)
}
modified src/App.svelte
@@ -2,6 +2,7 @@
  import { onMount } from "svelte";

  import { invoke } from "@tauri-apps/api/core";
+
  import { listen } from "@tauri-apps/api/event";

  import * as router from "@app/lib/router";
  import { nodeRunning } from "@app/lib/events";
@@ -10,9 +11,9 @@

  import AuthenticationError from "@app/views/AuthenticationError.svelte";
  import Home from "@app/views/Home.svelte";
+
  import Issue from "@app/views/repo/Issue.svelte";
  import Issues from "@app/views/repo/Issues.svelte";
  import Patches from "@app/views/repo/Patches.svelte";
-
  import { listen } from "@tauri-apps/api/event";

  const activeRouteStore = router.activeRouteStore;

@@ -51,6 +52,8 @@
  <!-- Don't show anything -->
{:else if $activeRouteStore.resource === "home"}
  <Home {...$activeRouteStore.params} />
+
{:else if $activeRouteStore.resource === "repo.issue"}
+
  <Issue {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.issues"}
  <Issues {...$activeRouteStore.params} />
{:else if $activeRouteStore.resource === "repo.patches"}
modified src/components/InlineTitle.svelte
@@ -16,6 +16,7 @@
    font-family: var(--font-family-monospace);
    padding: 0.125rem 0.25rem;
    background-color: var(--color-fill-ghost);
+
    font-size: inherit;
  }
</style>

modified src/components/IssueTeaser.svelte
@@ -1,22 +1,20 @@
<script lang="ts">
  import type { Issue } from "@bindings/Issue";

-
  import { formatOid, formatTimestamp } from "@app/lib/utils";
+
  import {
+
    formatOid,
+
    formatTimestamp,
+
    issueStatusBackgroundColor,
+
    issueStatusColor,
+
  } from "@app/lib/utils";
+
  import { push } from "@app/lib/router";

  import Icon from "./Icon.svelte";
  import InlineTitle from "./InlineTitle.svelte";
  import NodeId from "./NodeId.svelte";

  export let issue: Issue;
-

-
  const statusColor: Record<Issue["state"]["status"], string> = {
-
    open: "var(--color-fill-success)",
-
    closed: "var(--color-foreground-red)",
-
  };
-
  const statusBackgroundColor: Record<Issue["state"]["status"], string> = {
-
    open: "var(--color-fill-diff-green)",
-
    closed: "var(--color-fill-diff-red)",
-
  };
+
  export let rid: string;
</script>

<style>
@@ -49,12 +47,19 @@
  }
</style>

-
<div class="issue-teaser">
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
+
<div
+
  tabindex="0"
+
  role="button"
+
  class="issue-teaser"
+
  onclick={() => {
+
    void push({ resource: "repo.issue", rid, issue: issue.id });
+
  }}>
  <div class="global-flex">
    <div
      class="global-counter status"
-
      style:color={statusColor[issue.state.status]}
-
      style:background-color={statusBackgroundColor[issue.state.status]}>
+
      style:color={issueStatusColor[issue.state.status]}
+
      style:background-color={issueStatusBackgroundColor[issue.state.status]}>
      <Icon name="issue" />
    </div>
    <div
added src/components/Markdown.svelte
@@ -0,0 +1,382 @@
+
<script lang="ts">
+
  import dompurify from "dompurify";
+
  import matter from "@radicle/gray-matter";
+
  import { afterUpdate } from "svelte";
+
  import { toDom } from "hast-util-to-dom";
+

+
  import { Renderer, markdownWithExtensions } from "@app/lib/markdown";
+
  import { highlight } from "@app/lib/syntax";
+
  import { twemoji, scrollIntoView } from "@app/lib/utils";
+

+
  export let content: string;
+
  // If true, add <br> on a single line break
+
  export let breaks: boolean = false;
+

+
  let container: HTMLElement;
+
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+
  let frontMatter: [string, any][] | undefined = undefined;
+

+
  $: {
+
    try {
+
      const doc = matter(content);
+
      content = doc.content;
+
      frontMatter = Object.entries(doc.data).filter(
+
        ([, val]) => typeof val === "string" || typeof val === "number",
+
      );
+
    } catch (error) {
+
      console.error("Not able to parse frontmatter: ", error);
+
    }
+
  }
+

+
  function render(content: string): string {
+
    return dompurify.sanitize(
+
      markdownWithExtensions.parse(content, {
+
        renderer: new Renderer(),
+
        breaks,
+
      }) as string,
+
    );
+
  }
+

+
  afterUpdate(async () => {
+
    for (const e of container.querySelectorAll("a")) {
+
      try {
+
        const url = new URL(e.href);
+
        if (url.origin !== window.origin) {
+
          e.target = "_blank";
+
        }
+
      } catch (e) {
+
        console.warn("Not able to parse url", e);
+
      }
+
      // Don't underline <a> tags that contain images.
+
      // Make an exception for emojis.
+
      if (
+
        e.firstElementChild instanceof HTMLImageElement &&
+
        !e.firstElementChild.classList.contains("txt-emoji")
+
      ) {
+
        e.classList.add("no-underline");
+
      }
+
    }
+

+
    // Replace standard HTML checkboxes with our custom radicle-icon-small element
+
    for (const i of container.querySelectorAll('input[type="checkbox"]')) {
+
      i.parentElement?.classList.add("task-item");
+

+
      const checkbox = document.createElement("radicle-icon-small");
+
      const checked = i.getAttribute("checked");
+
      checkbox.setAttribute(
+
        "name",
+
        checked === null ? "checkbox-unchecked" : "checkbox-checked",
+
      );
+
      i.insertAdjacentElement("beforebegin", checkbox);
+
      i.remove();
+
    }
+

+
    // Replaces code blocks in the background with highlighted code.
+
    const prefix = "language-";
+
    const nodes = Array.from(document.body.querySelectorAll("pre code"));
+

+
    const treeChanges: Promise<void>[] = [];
+

+
    for (const node of nodes) {
+
      const className = Array.from(node.classList).find(name =>
+
        name.startsWith(prefix),
+
      );
+
      if (!className) continue;
+

+
      treeChanges.push(
+
        highlight(node.textContent ?? "", className.slice(prefix.length))
+
          .then(tree => {
+
            if (tree) {
+
              node.replaceChildren(toDom(tree, { fragment: true }));
+
            }
+
          })
+
          .catch(e => console.warn("Not able to highlight code block", e)),
+
      );
+
    }
+

+
    await Promise.allSettled(treeChanges);
+

+
    if (window.location.hash) {
+
      scrollIntoView(window.location.hash.substring(1));
+
    }
+
  });
+
</script>
+

+
<style>
+
  :global(html) {
+
    scroll-padding-top: 4rem;
+
  }
+
  .markdown {
+
    word-break: break-word;
+
    -webkit-touch-callout: initial;
+
    -webkit-user-select: text;
+
    user-select: text;
+
  }
+
  .front-matter {
+
    font-size: var(--font-size-tiny);
+
    font-family: var(--font-family-monospace);
+
    border: 1px dashed var(--color-border-default);
+
    padding: 0.5rem;
+
    margin-bottom: 2rem;
+
  }
+
  .front-matter table {
+
    border-collapse: collapse;
+
  }
+
  .front-matter table td {
+
    padding: 0.125rem 1rem;
+
  }
+
  .front-matter table td:first-child {
+
    padding-left: 0.5rem;
+
  }
+

+
  .markdown :global(h1) {
+
    font-size: calc(var(--font-size-x-large) * 0.75);
+
    font-weight: var(--font-weight-semibold);
+
    padding: 1rem 0 0.5rem 0;
+
    margin: 0 0 0.75rem;
+
    border-bottom: 1px solid var(--color-border-hint);
+
  }
+

+
  .markdown :global(h2) {
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-regular);
+
    padding: 0.25rem 0;
+
    margin: 2rem 0 0.5rem;
+
    border-bottom: 1px solid var(--color-border-hint);
+
  }
+

+
  .markdown :global(.pre-wrapper) {
+
    position: relative;
+
    margin: 1rem 0;
+
  }
+

+
  .markdown :global(radicle-clipboard) {
+
    display: none;
+
    position: absolute;
+
    right: 0.5rem;
+
    top: 0.5rem;
+
  }
+

+
  .markdown :global(radicle-clipboard) {
+
    background-color: var(--color-fill-ghost);
+
  }
+

+
  .markdown :global(.pre-wrapper:hover > radicle-clipboard) {
+
    display: flex;
+
  }
+

+
  .markdown :global(h3) {
+
    font-size: calc(var(--font-size-medium) * 0.9);
+
    font-weight: var(--font-weight-semibold);
+
    padding: 0.5rem 0;
+
    margin: 1rem 0 0.25rem;
+
  }
+

+
  .markdown :global(h4) {
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-regular);
+
    padding: 0.5rem 0;
+
    margin: 1rem 0 0.125rem;
+
  }
+

+
  .markdown :global(h5),
+
  .markdown :global(h6) {
+
    font-weight: var(--font-weight-semibold);
+
    font-size: var(--font-size-small);
+
    padding: 0.35rem 0;
+
    margin: 1rem 0 0.125rem;
+
  }
+

+
  .markdown :global(h6) {
+
    color: var(--color-foreground-dim);
+
  }
+

+
  .markdown :global(p) {
+
    line-height: 1.625rem;
+
    margin-top: 0;
+
    margin-bottom: 0.625rem;
+
  }
+

+
  .markdown :global(p:only-child) {
+
    margin-bottom: 0;
+
  }
+

+
  .markdown :global(li.task-item) {
+
    display: flex;
+
    align-items: center;
+
    gap: 0.5rem;
+
    margin-left: -1.2rem;
+
    color: var(--color-foreground-dim);
+
  }
+
  .markdown :global(li.task-item:not(:last-child)) {
+
    margin-bottom: 0.25rem;
+
  }
+

+
  .markdown :global(blockquote) {
+
    color: var(--color-foreground-dim);
+
    border-left: 0.3rem solid var(--color-fill-ghost);
+
    padding: 0 0 0 1rem;
+
    margin: 1rem 0 1rem 0;
+
  }
+

+
  .markdown :global(strong) {
+
    font-weight: var(--font-weight-semibold);
+
  }
+

+
  .markdown :global(.footnote-ref) {
+
    vertical-align: top;
+
    position: relative;
+
    top: -0.4rem;
+
  }
+
  .markdown :global(.footnote-ref),
+
  .markdown :global(.footnote > .marker),
+
  .markdown :global(.footnote > .ref-arrow) {
+
    color: var(--color-foreground-dim);
+
  }
+
  .markdown :global(.footnote-ref:hover),
+
  .markdown :global(.footnote .ref-arrow:hover) {
+
    color: var(--color-background-default);
+
  }
+
  .markdown :global(.footnote) {
+
    margin-bottom: 0;
+
  }
+

+
  .markdown :global(img) {
+
    border-style: none;
+
    max-width: 100%;
+
  }
+

+
  .markdown :global(code) {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-small);
+
    background-color: var(--color-fill-ghost);
+
    padding: 0.125rem 0.25rem;
+
  }
+

+
  .markdown :global(pre > code) {
+
    background: none;
+
    padding: 0;
+
  }
+

+
  .markdown :global(:not(pre) > code) {
+
    font-size: inherit;
+
  }
+

+
  .markdown :global(pre) {
+
    font-family: var(--font-family-monospace);
+
    font-size: var(--font-size-regular);
+
    background-color: var(--color-fill-ghost);
+
    padding: 1rem !important;
+
    overflow: scroll;
+
    scrollbar-width: none;
+
  }
+

+
  .markdown :global(pre::-webkit-scrollbar) {
+
    display: none;
+
  }
+

+
  .markdown :global(a),
+
  .markdown :global(a > code) {
+
    color: var(--color-foreground-contrast);
+
    background: none;
+
    padding: 0;
+
  }
+
  .markdown :global(a) {
+
    text-decoration: underline;
+
    text-decoration-color: var(--color-foreground-dim);
+
  }
+
  .markdown :global(a.no-underline) {
+
    text-decoration: none;
+
  }
+
  .markdown :global(a:hover) {
+
    text-decoration-color: var(--color-foreground-contrast);
+
  }
+

+
  .markdown :global(hr) {
+
    height: 0;
+
    margin: 1rem 0;
+
    overflow: hidden;
+
    background: transparent;
+
    border: 0;
+
    border-bottom: 1px solid var(--color-border-hint);
+
  }
+

+
  .markdown :global(ol) {
+
    line-height: 1.625;
+
    list-style-type: decimal;
+
    margin-bottom: 1rem;
+
    padding-left: 1.5rem;
+
  }
+

+
  .markdown :global(ul) {
+
    line-height: 1.625;
+
    list-style-type: inherit;
+
    padding-left: 1.25rem;
+
    margin-bottom: 1rem;
+
  }
+
  .markdown :global(.list-content) {
+
    margin: 1rem 0;
+
  }
+
  /* Allows the parent to specify its own bottom margin */
+
  .markdown :global(> :last-child) {
+
    margin-bottom: 0;
+
  }
+
  .markdown :global(li > ul) {
+
    margin-bottom: 0rem;
+
  }
+
  .markdown :global(li > ol) {
+
    margin-bottom: 0rem;
+
  }
+
  .markdown :global(table) {
+
    margin: 1.5rem 0;
+
    border-collapse: collapse;
+
    border-style: hidden;
+
    box-shadow: 0 0 0 1px var(--color-border-hint);
+
    overflow: hidden;
+
  }
+
  .markdown :global(td) {
+
    text-align: left;
+
    text-overflow: ellipsis;
+
    border: 1px solid var(--color-border-hint);
+
    padding: 0.5rem 1rem;
+
  }
+
  .markdown :global(tr:nth-child(even)) {
+
    background-color: var(--color-background-default);
+
  }
+
  .markdown :global(th) {
+
    text-align: center;
+
    padding: 0.5rem 1rem;
+
  }
+

+
  .markdown :global(*:first-child:not(pre)) {
+
    padding-top: 0 !important;
+
  }
+
  .markdown :global(*:first-child) {
+
    margin-top: 0 !important;
+
  }
+
  .markdown :global(dl dt) {
+
    font-style: italic;
+
    margin-top: 1rem;
+
  }
+
  .markdown :global(dl dd) {
+
    margin: 0 0 0 2rem;
+
  }
+
</style>
+

+
{#if frontMatter && frontMatter.length > 0}
+
  <div class="front-matter">
+
    <table>
+
      {#each frontMatter as [key, val]}
+
        <!-- svelte-ignore node_invalid_placement_ssr -->
+
        <tr>
+
          <td><span class="txt-bold">{key}</span></td>
+
          <td>{val}</td>
+
        </tr>
+
      {/each}
+
    </table>
+
  </div>
+
{/if}
+

+
<div class="markdown" bind:this={container} use:twemoji={{ exclude: ["21a9"] }}>
+
  {@html render(content)}
+
</div>
added src/lib/emojis.ts
@@ -0,0 +1,49 @@
+
/* eslint-disable @typescript-eslint/naming-convention */
+

+
const emojis: { [key: string]: string } = {
+
  100: "💯",
+
  question: "❓",
+
  exclamation: "❗",
+
  sunrise: "🌅",
+
  rainbow: "🌈",
+
  ocean: "🌊",
+
  volcano: "🌋",
+
  seedling: "🌱",
+
  maple_leaf: "🍁",
+
  wood: "🪵",
+
  evergreen_tree: "🌲",
+
  gift: "🎁",
+
  santa: "🎅",
+
  tada: "🎉",
+
  art: "🎨",
+
  dart: "🎯",
+
  bug: "🐛",
+
  wave: "👋",
+
  ok_hand: "👌",
+
  building_construction: "🏗️",
+
  "+1": "👍",
+
  thumbsup: "👍",
+
  "-1": "👎",
+
  thumbsdown: "👎",
+
  clap: "👏",
+
  open_hands: "👐",
+
  ghost: "👻",
+
  alien: "👽",
+
  skull: "💀",
+
  boom: "💥",
+
  poop: "💩",
+
  muscle: "💪",
+
  mage: "🧙‍♀️",
+
  bow: "🙇‍♂️",
+
  see_no_evil: "🙈",
+
  hear_no_evil: "🙉",
+
  speak_no_evil: "🙊",
+
  pray: "🙏",
+
  rocket: "🚀",
+
  construction: "🚧",
+
  rotating_light: "🚨",
+
  no_entry_sign: "🚫",
+
  clown_face: "🤡",
+
};
+

+
export default emojis;
added src/lib/markdown.ts
@@ -0,0 +1,82 @@
+
import type { MarkedExtension, Tokens } from "marked";
+

+
import dompurify from "dompurify";
+
import katexMarkedExtension from "marked-katex-extension";
+
import markedFootnote from "marked-footnote";
+
import markedLinkifyIt from "marked-linkify-it";
+
import { Marked, Renderer as BaseRenderer } from "marked";
+
import { markedEmoji } from "marked-emoji";
+

+
import emojis from "@app/lib/emojis";
+

+
dompurify.setConfig({
+
  // eslint-disable-next-line @typescript-eslint/naming-convention
+
  SANITIZE_DOM: false,
+
  // eslint-disable-next-line @typescript-eslint/naming-convention
+
  FORBID_TAGS: ["textarea", "style"],
+
});
+

+
// Converts self closing anchor tags into empty anchor tags, to avoid erratic wrapping behaviour
+
// e.g. <a name="test"/> -> <a name="test"></a>
+
const anchorMarkedExtension = {
+
  name: "sanitizedAnchor",
+
  level: "block",
+
  start: (src: string) => src.match(/<a name="([\w]+)"\/>/)?.index,
+
  tokenizer(src: string) {
+
    const match = src.match(/^<a name="([\w]+)"\/>/);
+
    if (match) {
+
      return {
+
        type: "sanitizedAnchor",
+
        raw: match[0],
+
        text: match[1].trim(),
+
      };
+
    }
+
  },
+
  renderer: (token: Tokens.Generic): string => `<a name="${token.text}"></a>`,
+
};
+

+
export class Renderer extends BaseRenderer {
+
  /**
+
   * If `baseUrl` is provided, all hrefs attributes in anchor tags, except those
+
   * starting with `#`, are resolved with respect to `baseUrl`
+
   */
+
  constructor() {
+
    super();
+
  }
+
  // Overwrites the rendering of heading tokens.
+
  // Since there are possible non ASCII characters in headings,
+
  // we escape them by replacing them with dashes and,
+
  // trim eventual dashes on each side of the string.
+
  heading({ tokens, depth }: Tokens.Heading) {
+
    const text = this.parser.parseInline(tokens);
+
    const escapedText = text
+
      // By lowercasing we avoid casing mismatches, between headings and links.
+
      .toLowerCase()
+
      .replace(/[^\w]+/g, "-")
+
      .replace(/^-|-$/g, "");
+

+
    return `<h${depth} id="${escapedText}">${text}</h${depth}>`;
+
  }
+

+
  link({ href, title, tokens }: Tokens.Link): string {
+
    const text = this.parser.parseInline(tokens);
+
    if (href.startsWith("#")) {
+
      // By lowercasing we avoid casing mismatches, between headings and links.
+
      return `<a ${title ? `title="${title}"` : ""} href="${href.toLowerCase()}">${text}</a>`;
+
    }
+

+
    return `<a ${title ? `title="${title}"` : ""} href="${href}">${text}</a>`;
+
  }
+
}
+

+
export default new Marked();
+

+
export const markdownWithExtensions = new Marked(
+
  katexMarkedExtension({ throwOnError: false }),
+
  markedLinkifyIt({}, { fuzzyLink: false }),
+
  markedFootnote({ refMarkers: true }),
+
  markedEmoji({ emojis }),
+
  ((): MarkedExtension => ({
+
    extensions: [anchorMarkedExtension],
+
  }))(),
+
);
modified src/lib/router.ts
@@ -118,6 +118,7 @@ export function routeToPath(route: Route): string {
  } else if (route.resource === "authenticationError") {
    return "/authenticationError";
  } else if (
+
    route.resource === "repo.issue" ||
    route.resource === "repo.issues" ||
    route.resource === "repo.patches"
  ) {
modified src/lib/router/definitions.ts
@@ -1,15 +1,17 @@
import type { Config } from "@bindings/Config";
import type { RepoInfo } from "@bindings/RepoInfo";
import type {
-
  RepoIssuesRoute,
-
  RepoPatchesRoute,
+
  LoadedRepoIssueRoute,
  LoadedRepoIssuesRoute,
  LoadedRepoPatchesRoute,
+
  RepoIssueRoute,
+
  RepoIssuesRoute,
+
  RepoPatchesRoute,
} from "@app/views/repo/router";

import { invoke } from "@tauri-apps/api/core";

-
import { loadIssues, loadPatches } from "@app/views/repo/router";
+
import { loadIssues, loadIssue, loadPatches } from "@app/views/repo/router";

interface BootingRoute {
  resource: "booting";
@@ -36,6 +38,7 @@ export type Route =
  | AuthenticationErrorRoute
  | BootingRoute
  | HomeRoute
+
  | RepoIssueRoute
  | RepoIssuesRoute
  | RepoPatchesRoute;

@@ -43,6 +46,7 @@ export type LoadedRoute =
  | AuthenticationErrorRoute
  | BootingRoute
  | LoadedHomeRoute
+
  | LoadedRepoIssueRoute
  | LoadedRepoIssuesRoute
  | LoadedRepoPatchesRoute;

@@ -54,6 +58,8 @@ export async function loadRoute(
    const repos: RepoInfo[] = await invoke("list_repos");
    const config: Config = await invoke("config");
    return { resource: "home", params: { repos, config } };
+
  } else if (route.resource === "repo.issue") {
+
    return loadIssue(route);
  } else if (route.resource === "repo.issues") {
    return loadIssues(route);
  } else if (route.resource === "repo.patches") {
added src/lib/syntax.ts
@@ -0,0 +1,239 @@
+
import type { ElementContent, Root } from "hast";
+

+
import onigurumaWASMUrl from "vscode-oniguruma/release/onig.wasm?url";
+
import sourceAsciiDoc from "@wooorm/starry-night/text.html.asciidoc";
+
import sourceDockerfile from "@wooorm/starry-night/source.dockerfile";
+
import sourceErlang from "@wooorm/starry-night/source.erlang";
+
import sourceSolidity from "@wooorm/starry-night/source.solidity";
+
import sourceSvelte from "@wooorm/starry-night/source.svelte";
+
import sourceSass from "@wooorm/starry-night/source.sass";
+
import sourceToml from "@wooorm/starry-night/source.toml";
+
import sourceTsx from "@wooorm/starry-night/source.tsx";
+
import sourceNix from "@wooorm/starry-night/source.nix";
+
import sourceGitconfig from "@wooorm/starry-night/source.gitconfig";
+
import sourceGitignore from "@wooorm/starry-night/source.gitignore";
+
import sourceGitrevlist from "@wooorm/starry-night/source.git-revlist";
+
import sourceGitattributes from "@wooorm/starry-night/source.gitattributes";
+
import sourceJson from "@wooorm/starry-night/source.json";
+
import sourceNpmrc from "@wooorm/starry-night/source.ini.npmrc";
+
import sourceGradle from "@wooorm/starry-night/source.groovy.gradle";
+
import sourceBatchfile from "@wooorm/starry-night/source.batchfile";
+
import sourceEditorconfig from "@wooorm/starry-night/source.editorconfig";
+
import sourceHaproxyConfig from "@wooorm/starry-night/source.haproxy-config";
+
import sourceDotenv from "@wooorm/starry-night/source.dotenv";
+
import sourceZig from "@wooorm/starry-night/source.zig";
+
import textHtmlVue from "@wooorm/starry-night/text.html.vue";
+
import textHtmlDjango from "@wooorm/starry-night/text.html.django";
+
import textRobotsTxt from "@wooorm/starry-night/text.robots-txt";
+
import textZoneFile from "@wooorm/starry-night/text.zone_file";
+
import etc from "@wooorm/starry-night/etc";
+
import goMod from "@wooorm/starry-night/go.mod";
+
import goSum from "@wooorm/starry-night/go.sum";
+

+
import { createStarryNight, common, type Grammar } from "@wooorm/starry-night";
+

+
export { type Root };
+

+
export const grammars = [
+
  ...common,
+
  sourceAsciiDoc,
+
  sourceToml,
+
  sourceErlang,
+
  sourceSolidity,
+
  sourceSvelte,
+
  sourceSass,
+
  sourceTsx,
+
  sourceDockerfile,
+
  sourceNix,
+
  sourceGitconfig,
+
  sourceGitignore,
+
  sourceGitrevlist,
+
  sourceGitattributes,
+
  sourceNpmrc,
+
  sourceGradle,
+
  sourceBatchfile,
+
  sourceEditorconfig,
+
  sourceHaproxyConfig,
+
  sourceDotenv,
+
  sourceZig,
+
  textHtmlVue,
+
  textHtmlDjango,
+
  textRobotsTxt,
+
  textZoneFile,
+
  etc,
+
  goMod,
+
  goSum,
+
  {
+
    extensions: [".hintrc"],
+
    names: ["json"],
+
    patterns: [sourceJson],
+
    scopeName: "source.json",
+
  },
+
  {
+
    extensions: [
+
      ".npmignore",
+
      ".eslintignore",
+
      ".dockerignore",
+
      ".nuxtignore",
+
      ".vscodeignore",
+
    ],
+
    names: ["ignore"],
+
    patterns: [sourceGitignore],
+
    scopeName: "source.gitignore",
+
  },
+
  {
+
    extensions: [".sample", ".example", ".template"],
+
    names: [".env.sample", ".env.example", ".env.template"],
+
    patterns: [sourceDotenv],
+
    scopeName: "source.dotenv",
+
  },
+
  {
+
    extensions: [".mod"],
+
    names: ["go.mod"],
+
    patterns: [goMod],
+
    scopeName: "go.mode",
+
  },
+
  {
+
    extensions: [".sum"],
+
    names: ["go.sum"],
+
    patterns: [goSum],
+
    scopeName: "go.sum",
+
  },
+
  // A grammar that doesn't do any parsing, but needed for files without a known filetype.
+
  {
+
    extensions: [""],
+
    names: ["raw-format"],
+
    patterns: [],
+
    scopeName: "text.raw",
+
  },
+
] satisfies Grammar[];
+

+
let starryNight: Awaited<ReturnType<typeof createStarryNight>>;
+

+
export async function highlight(
+
  content: string,
+
  grammar: string,
+
): Promise<Root> {
+
  if (starryNight === undefined) {
+
    starryNight = await createStarryNight(grammars, {
+
      getOnigurumaUrlFetch: () => new URL(onigurumaWASMUrl, import.meta.url),
+
    });
+
  }
+
  const scope = starryNight.flagToScope(grammar);
+
  return starryNight.highlight(content, scope ?? "text.raw");
+
}
+

+
export function lineNumbersGutter(tree: Root) {
+
  const replacement: ElementContent[] = [];
+
  const search = /\r?\n|\r/g;
+
  let index = -1;
+
  let start = 0;
+
  let startTextRemainder = "";
+
  let lineNumber = 0;
+

+
  while (++index < tree.children.length) {
+
    const child = tree.children[index];
+

+
    if (child.type === "text") {
+
      let textStart = 0;
+
      let match = search.exec(child.value);
+

+
      while (match) {
+
        // Nodes in this line.
+
        const line = tree.children.slice(start, index) as ElementContent[];
+

+
        // Prepend text from a partial matched earlier text.
+
        if (startTextRemainder) {
+
          line.unshift({ type: "text", value: startTextRemainder });
+
          startTextRemainder = "";
+
        }
+

+
        // Append text from this text.
+
        if (match.index > textStart) {
+
          line.push({
+
            type: "text",
+
            value: child.value.slice(textStart, match.index),
+
          });
+
        }
+

+
        // Add a line, and the eol.
+
        lineNumber += 1;
+
        replacement.push(createLine(line, lineNumber), {
+
          type: "text",
+
          value: match[0],
+
        });
+

+
        start = index + 1;
+
        textStart = match.index + match[0].length;
+
        match = search.exec(child.value);
+
      }
+

+
      // If we matched, make sure to not drop the text after the last line ending.
+
      if (start === index + 1) {
+
        startTextRemainder = child.value.slice(textStart);
+
      }
+
    }
+
  }
+

+
  const line = tree.children.slice(start) as ElementContent[];
+
  // Prepend text from a partial matched earlier text.
+
  if (startTextRemainder) {
+
    line.unshift({ type: "text", value: startTextRemainder });
+
    startTextRemainder = "";
+
  }
+

+
  if (line.length > 0) {
+
    lineNumber += 1;
+
    replacement.push(createLine(line, lineNumber));
+
  }
+

+
  // Replace children with new array.
+
  tree.children = replacement;
+

+
  return tree;
+
}
+

+
function createLine(children: ElementContent[], line: number): ElementContent {
+
  return {
+
    type: "element",
+
    tagName: "tr",
+
    properties: {
+
      class: "line",
+
      id: "L" + line,
+
    },
+
    children: [
+
      {
+
        type: "element",
+
        tagName: "td",
+
        properties: {
+
          className: "line-number",
+
        },
+
        children: [
+
          {
+
            type: "element",
+
            tagName: "a",
+
            properties: { href: "#L" + line },
+
            children: [{ type: "text", value: line.toString() }],
+
          },
+
        ],
+
      },
+
      {
+
        type: "element",
+
        tagName: "td",
+
        properties: {
+
          className: "line-content",
+
        },
+
        children: [
+
          {
+
            type: "element",
+
            tagName: "pre",
+
            properties: {
+
              className: "content",
+
            },
+
            children,
+
          },
+
        ],
+
      },
+
    ],
+
  };
+
}
modified src/lib/utils.ts
@@ -1,4 +1,7 @@
+
import type { Issue } from "@bindings/Issue";
+

import bs58 from "bs58";
+
import twemojiModule from "twemoji";

export const unreachable = (value: never): never => {
  throw new Error(`Unreachable code: ${value}`);
@@ -75,3 +78,40 @@ export const formatTimestamp = (

  return new Date(timestamp).toUTCString();
};
+

+
export function twemoji(
+
  node: HTMLElement,
+
  { exclude }: { exclude: string[] } = { exclude: [] },
+
) {
+
  twemojiModule.parse(node, {
+
    callback: (icon, options) => {
+
      const { base, size, ext } = options as Record<string, string>;
+
      if (!exclude.includes(icon)) {
+
        return `${base}${size}/${icon}${ext}`;
+
      }
+
      return false;
+
    },
+
    base: "/",
+
    folder: "twemoji",
+
    ext: ".svg",
+
    className: `txt-emoji`,
+
  });
+
}
+

+
export function scrollIntoView(id: string, options?: ScrollIntoViewOptions) {
+
  const lineElement = document.getElementById(id);
+
  if (lineElement) lineElement.scrollIntoView(options);
+
}
+

+
export const issueStatusColor: Record<Issue["state"]["status"], string> = {
+
  open: "var(--color-fill-success)",
+
  closed: "var(--color-foreground-red)",
+
};
+

+
export const issueStatusBackgroundColor: Record<
+
  Issue["state"]["status"],
+
  string
+
> = {
+
  open: "var(--color-fill-diff-green)",
+
  closed: "var(--color-fill-diff-red)",
+
};
added src/views/repo/Issue.svelte
@@ -0,0 +1,145 @@
+
<script lang="ts">
+
  import type { Config } from "@bindings/Config";
+
  import type { Issue } from "@bindings/Issue";
+
  import type { RepoInfo } from "@bindings/RepoInfo";
+

+
  import { formatTimestamp, formatOid, issueStatusColor } from "@app/lib/utils";
+

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
+
  import Icon from "@app/components/Icon.svelte";
+
  import InlineTitle from "@app/components/InlineTitle.svelte";
+
  import Layout from "./Layout.svelte";
+
  import Link from "@app/components/Link.svelte";
+
  import Markdown from "@app/components/Markdown.svelte";
+
  import NodeId from "@app/components/NodeId.svelte";
+

+
  export let repo: RepoInfo;
+
  export let issue: Issue;
+
  export let issues: Issue[];
+
  export let config: Config;
+

+
  $: project = repo.payloads["xyz.radicle.project"]!;
+
</script>
+

+
<style>
+
  .title {
+
    font-size: var(--font-size-medium);
+
    font-weight: var(--font-weight-medium);
+
    -webkit-user-select: text;
+
    user-select: text;
+
    margin-bottom: 1rem;
+
    margin-top: 0.35rem;
+
  }
+
  .issue-teaser {
+
    max-width: 11rem;
+
    white-space: nowrap;
+
  }
+
  .issue-list {
+
    margin-top: 0.5rem;
+
    display: flex;
+
    flex-direction: column;
+
    gap: 0.5rem;
+
    padding-bottom: 1rem;
+
  }
+
  .content {
+
    padding: 0 1rem 1rem 1rem;
+
  }
+

+
  .body {
+
    background-color: var(--color-background-float);
+
    padding: 1rem;
+
  }
+
</style>
+

+
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
  <svelte:fragment slot="breadcrumbs">
+
    <Link route={{ resource: "home" }}>
+
      <NodeId
+
        nodeId={config.publicKey}
+
        alias={config.alias}
+
        styleFontFamily="var(--font-family-sans-serif)"
+
        styleFontSize="var(--font-size-tiny)" />
+
    </Link>
+
    <Link route={{ resource: "repo.issues", rid: repo.rid, status: "open" }}>
+
      <div class="global-flex">
+
        <Icon name="chevron-right" />
+
        {project.data.name}
+
      </div>
+
    </Link>
+
    <Icon name="chevron-right" />
+
    Issues
+
  </svelte:fragment>
+

+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={issue.id} />
+
  </svelte:fragment>
+

+
  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <div style:margin-left="0.5rem">
+
        <Icon name="issue" />
+
      </div>
+
      <span class="txt-small txt-semibold">Issues</span>
+
      <div class="global-flex txt-small" style:margin-left="auto">
+
        <div
+
          class="global-counter"
+
          style:padding="0 6px"
+
          style:background-color="var(--color-fill-ghost)"
+
          style:gap="4px">
+
          {project.meta.issues.open + project.meta.issues.closed}
+
        </div>
+
      </div>
+
    </Border>
+

+
    <div class="issue-list">
+
      {#each issues as sidebarIssue}
+
        <Link
+
          variant="tab"
+
          route={{
+
            resource: "repo.issue",
+
            rid: repo.rid,
+
            issue: sidebarIssue.id,
+
          }}>
+
          <div class="global-flex">
+
            <div
+
              style:color={issueStatusColor[sidebarIssue.state.status]}
+
              style:margin-left="2px">
+
              <Icon name="issue" />
+
            </div>
+
            <span class="txt-small issue-teaser txt-overflow">
+
              <InlineTitle content={sidebarIssue.title} fontSize="small" />
+
            </span>
+
          </div>
+
        </Link>
+
      {/each}
+
    </div>
+
  </svelte:fragment>
+

+
  <div class="content">
+
    <div class="title">
+
      <InlineTitle content={issue.title} fontSize="medium" />
+
    </div>
+
    <div class="txt-small body">
+
      {#if issue.discussion[0].edits.slice(-1)[0].body !== ""}
+
        <Markdown
+
          breaks
+
          content={issue.discussion[0].edits.slice(-1)[0].body} />
+
      {:else}
+
        <span class="txt-missing">No description.</span>
+
      {/if}
+
      <div class="global-flex txt-small" style:margin-top="1.5rem">
+
        <NodeId
+
          nodeId={issue.author.did.replace("did:key:", "")}
+
          alias={issue.author.alias} />
+
        opened
+
        <div class="global-oid">{formatOid(issue.id)}</div>
+
        {formatTimestamp(issue.timestamp)}
+
      </div>
+
    </div>
+
  </div>
+
</Layout>
modified src/views/repo/Issues.svelte
@@ -6,10 +6,13 @@

  import Layout from "./Layout.svelte";

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import IssueTeaser from "@app/components/IssueTeaser.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
+
  import RepoHeader from "@app/components/RepoHeader.svelte";

  export let repo: RepoInfo;
  export let issues: Issue[];
@@ -28,7 +31,7 @@
  }
</style>

-
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
<Layout>
  <svelte:fragment slot="breadcrumbs">
    <Link route={{ resource: "home" }}>
      <NodeId
@@ -47,7 +50,22 @@
    Issues
  </svelte:fragment>

+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={repo.rid} />
+
  </svelte:fragment>
+

  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <RepoHeader
+
        {repo}
+
        selfDid={`did:key:${config.publicKey}`}
+
        emphasizedTitle={false} />
+
    </Border>
+

    <div class="global-flex txt-small" style:margin="0.5rem 0">
      <Link
        variant={status === "all" ? "active" : "tab"}
@@ -98,15 +116,17 @@

  <div class="list">
    {#each issues as issue}
-
      <IssueTeaser {issue} />
+
      <IssueTeaser {issue} rid={repo.rid} />
    {/each}

    {#if issues.length === 0}
-
      {#if status === "all"}
-
        No issues.
-
      {:else}
-
        No {status} issues.
-
      {/if}
+
      <div class="txt-missing txt-small">
+
        {#if status === "all"}
+
          No issues.
+
        {:else}
+
          No {status} issues.
+
        {/if}
+
      </div>
    {/if}
  </div>
</Layout>
modified src/views/repo/Layout.svelte
@@ -1,15 +1,7 @@
<script lang="ts">
-
  import type { RepoInfo } from "@bindings/RepoInfo";
-

-
  import Border from "@app/components/Border.svelte";
-
  import CopyableId from "@app/components/CopyableId.svelte";
  import Header from "@app/components/Header.svelte";
  import Icon from "@app/components/Icon.svelte";
  import NakedButton from "@app/components/NakedButton.svelte";
-
  import RepoHeader from "@app/components/RepoHeader.svelte";
-

-
  export let repo: RepoInfo;
-
  export let selfDid: string;

  let hidden = false;
</script>
@@ -30,6 +22,7 @@
    grid-column: 1 / 2;
    margin: 1rem 0.5rem 0 1rem;
    min-width: 14rem;
+
    overflow: scroll;
  }

  .content {
@@ -56,9 +49,11 @@
          <Icon name="sidebar" />
        </NakedButton>
      </svelte:fragment>
+

      <svelte:fragment slot="center">
-
        <CopyableId id={repo.rid} />
+
        <slot name="header-center" />
      </svelte:fragment>
+

      <svelte:fragment slot="breadcrumbs">
        <slot name="breadcrumbs" />
      </svelte:fragment>
@@ -66,14 +61,6 @@
  </div>

  <div class="sidebar" class:hidden>
-
    <Border
-
      hoverable={false}
-
      variant="ghost"
-
      styleWidth="100%"
-
      styleHeight="32px">
-
      <RepoHeader {repo} {selfDid} emphasizedTitle={false} />
-
    </Border>
-

    <slot name="sidebar" />
  </div>

modified src/views/repo/Patches.svelte
@@ -6,10 +6,13 @@

  import Layout from "./Layout.svelte";

+
  import Border from "@app/components/Border.svelte";
+
  import CopyableId from "@app/components/CopyableId.svelte";
  import Icon from "@app/components/Icon.svelte";
  import Link from "@app/components/Link.svelte";
  import NodeId from "@app/components/NodeId.svelte";
  import PatchTeaser from "@app/components/PatchTeaser.svelte";
+
  import RepoHeader from "@app/components/RepoHeader.svelte";

  export let repo: RepoInfo;
  export let patches: Patch[];
@@ -28,7 +31,7 @@
  }
</style>

-
<Layout {repo} selfDid={`did:key:${config.publicKey}`}>
+
<Layout>
  <svelte:fragment slot="breadcrumbs">
    <Link route={{ resource: "home" }}>
      <NodeId
@@ -46,8 +49,22 @@
    <Icon name="chevron-right" />
    Patches
  </svelte:fragment>
+
  <svelte:fragment slot="header-center">
+
    <CopyableId id={repo.rid} />
+
  </svelte:fragment>

  <svelte:fragment slot="sidebar">
+
    <Border
+
      hoverable={false}
+
      variant="ghost"
+
      styleWidth="100%"
+
      styleHeight="32px">
+
      <RepoHeader
+
        {repo}
+
        selfDid={`did:key:${config.publicKey}`}
+
        emphasizedTitle={false} />
+
    </Border>
+

    <div class="global-flex txt-small" style:margin="0.5rem 0">
      <Link
        variant="tab"
@@ -121,11 +138,13 @@
    {/each}

    {#if patches.length === 0}
-
      {#if status === "all"}
-
        No patches.
-
      {:else}
-
        No {status} patches.
-
      {/if}
+
      <div class="txt-missing txt-small">
+
        {#if status === "all"}
+
          No patches.
+
        {:else}
+
          No {status} patches.
+
        {/if}
+
      </div>
    {/if}
  </div>
</Layout>
modified src/views/repo/router.ts
@@ -8,6 +8,22 @@ import { unreachable } from "@app/lib/utils";

export type IssueStatus = "all" | Issue["state"]["status"];

+
export interface RepoIssueRoute {
+
  resource: "repo.issue";
+
  rid: string;
+
  issue: string;
+
}
+

+
export interface LoadedRepoIssueRoute {
+
  resource: "repo.issue";
+
  params: {
+
    repo: RepoInfo;
+
    config: Config;
+
    issue: Issue;
+
    issues: Issue[];
+
  };
+
}
+

export interface RepoIssuesRoute {
  resource: "repo.issues";
  rid: string;
@@ -42,8 +58,11 @@ export interface LoadedRepoPatchesRoute {
  };
}

-
export type RepoRoute = RepoIssuesRoute | RepoPatchesRoute;
-
export type LoadedRepoRoute = LoadedRepoIssuesRoute | LoadedRepoPatchesRoute;
+
export type RepoRoute = RepoIssueRoute | RepoIssuesRoute | RepoPatchesRoute;
+
export type LoadedRepoRoute =
+
  | LoadedRepoIssueRoute
+
  | LoadedRepoIssuesRoute
+
  | LoadedRepoPatchesRoute;

export async function loadPatches(
  route: RepoPatchesRoute,
@@ -81,10 +100,35 @@ export async function loadIssues(
  };
}

+
export async function loadIssue(
+
  route: RepoIssueRoute,
+
): Promise<LoadedRepoIssueRoute> {
+
  const repo: RepoInfo = await invoke("repo_by_id", {
+
    rid: route.rid,
+
  });
+
  const config: Config = await invoke("config");
+
  const issues: Issue[] = await invoke("list_issues", {
+
    rid: route.rid,
+
    status: "all",
+
  });
+
  const issue: Issue = await invoke("issues_by_id", {
+
    rid: route.rid,
+
    id: route.issue,
+
  });
+

+
  return {
+
    resource: "repo.issue",
+
    params: { repo, config, issue, issues },
+
  };
+
}
+

export function repoRouteToPath(route: RepoRoute): string {
  const pathSegments = ["/repos", route.rid];

-
  if (route.resource === "repo.issues") {
+
  if (route.resource === "repo.issue") {
+
    const url = [...pathSegments, "issues", route.issue].join("/");
+
    return url;
+
  } else if (route.resource === "repo.issues") {
    let url = [...pathSegments, "issues"].join("/");
    const searchParams = new URLSearchParams();
    if (route.status) {
@@ -114,11 +158,20 @@ export function repoUrlToRoute(

  if (rid) {
    if (resource === "issues") {
-
      const status = searchParams.get("status");
-
      if (status === "open" || status === "closed") {
-
        return { resource: "repo.issues", rid, status };
+
      const id = segments.shift();
+
      if (id) {
+
        return {
+
          resource: "repo.issue",
+
          rid,
+
          issue: id,
+
        };
      } else {
-
        return { resource: "repo.issues", rid, status: "all" };
+
        const status = searchParams.get("status");
+
        if (status === "open" || status === "closed") {
+
          return { resource: "repo.issues", rid, status };
+
        } else {
+
          return { resource: "repo.issues", rid, status: "all" };
+
        }
      }
    } else if (resource === "patches") {
      const status = searchParams.get("status");