feat: Quartz TUI

This commit is contained in:
saberzero1 2026-02-17 20:16:39 +01:00
parent 47de5cc55e
commit 292c1a1f2c
No known key found for this signature in database
21 changed files with 6720 additions and 276 deletions

1
.gitignore vendored
View File

@ -10,3 +10,4 @@ private/
.replit .replit
replit.nix replit.nix
.quartz/ .quartz/
quartz/cli/tui/dist/

1109
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"check": "tsc --noEmit && npx prettier . --check", "check": "tsc --noEmit && npx prettier . --check",
"format": "npx prettier . --write", "format": "npx prettier . --write",
"test": "tsx --test", "test": "tsx --test",
"build:tui": "node quartz/cli/build-tui.mjs",
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1", "profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1",
"install-plugins": "npx tsx ./quartz/plugins/loader/install-plugins.ts", "install-plugins": "npx tsx ./quartz/plugins/loader/install-plugins.ts",
"prebuild": "npm run install-plugins" "prebuild": "npm run install-plugins"
@ -41,6 +42,8 @@
"@floating-ui/dom": "^1.7.4", "@floating-ui/dom": "^1.7.4",
"@myriaddreamin/rehype-typst": "^0.6.0", "@myriaddreamin/rehype-typst": "^0.6.0",
"@napi-rs/simple-git": "0.1.22", "@napi-rs/simple-git": "0.1.22",
"@opentui/core": "^0.1.80",
"@opentui/react": "^0.1.80",
"ansi-truncate": "^1.4.0", "ansi-truncate": "^1.4.0",
"async-mutex": "^0.5.0", "async-mutex": "^0.5.0",
"chokidar": "^5.0.0", "chokidar": "^5.0.0",
@ -65,6 +68,7 @@
"preact-render-to-string": "^6.6.5", "preact-render-to-string": "^6.6.5",
"pretty-bytes": "^7.1.0", "pretty-bytes": "^7.1.0",
"pretty-time": "^1.1.0", "pretty-time": "^1.1.0",
"react": "^19.2.4",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"rehype-autolink-headings": "^7.1.0", "rehype-autolink-headings": "^7.1.0",
"rehype-citation": "^2.3.1", "rehype-citation": "^2.3.1",
@ -101,6 +105,7 @@
"@types/js-yaml": "^4.0.9", "@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.10", "@types/node": "^25.0.10",
"@types/pretty-time": "^1.1.5", "@types/pretty-time": "^1.1.5",
"@types/react": "^19.2.14",
"@types/source-map-support": "^0.5.10", "@types/source-map-support": "^0.5.10",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@types/yargs": "^17.0.35", "@types/yargs": "^17.0.35",

View File

@ -1,59 +1,11 @@
{ {
"version": "1.0.0", "version": "1.0.0",
"plugins": { "plugins": {
"explorer": { "alias-redirects": {
"source": "github:quartz-community/explorer", "source": "github:quartz-community/alias-redirects",
"resolved": "https://github.com/quartz-community/explorer.git", "resolved": "https://github.com/quartz-community/alias-redirects.git",
"commit": "09452e44a4c0e9b2c1d94e6e7a5dc98137dac230", "commit": "65897957ab8108d015cd4255d42d84df7377c083",
"installedAt": "2026-02-17T17:28:27.727Z" "installedAt": "2026-02-17T17:28:43.267Z"
},
"graph": {
"source": "github:quartz-community/graph",
"resolved": "https://github.com/quartz-community/graph.git",
"commit": "f61ef4aaad0a560428373508e49efa8bf5d0dbf6",
"installedAt": "2026-02-17T17:28:28.934Z"
},
"search": {
"source": "github:quartz-community/search",
"resolved": "https://github.com/quartz-community/search.git",
"commit": "5d206baacbad17e99925f61c6263e98a494258d1",
"installedAt": "2026-02-17T17:28:29.442Z"
},
"backlinks": {
"source": "github:quartz-community/backlinks",
"resolved": "https://github.com/quartz-community/backlinks.git",
"commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d",
"installedAt": "2026-02-17T17:28:30.109Z"
},
"table-of-contents": {
"source": "github:quartz-community/table-of-contents",
"resolved": "https://github.com/quartz-community/table-of-contents.git",
"commit": "38e7f0198114e4466cb39ea0c2ebb01e46847d39",
"installedAt": "2026-02-17T17:28:30.541Z"
},
"comments": {
"source": "github:quartz-community/comments",
"resolved": "https://github.com/quartz-community/comments.git",
"commit": "a2bee0c8f2f899f4f181a8a6f52ef300eb6bb8e4",
"installedAt": "2026-02-17T17:28:30.983Z"
},
"breadcrumbs": {
"source": "github:quartz-community/breadcrumbs",
"resolved": "https://github.com/quartz-community/breadcrumbs.git",
"commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d",
"installedAt": "2026-02-17T17:28:31.401Z"
},
"recent-notes": {
"source": "github:quartz-community/recent-notes",
"resolved": "https://github.com/quartz-community/recent-notes.git",
"commit": "96d5df9fc6d7e0e8bd3a90856faaa08d2ac3441b",
"installedAt": "2026-02-17T17:28:31.827Z"
},
"latex": {
"source": "github:quartz-community/latex",
"resolved": "https://github.com/quartz-community/latex.git",
"commit": "d7d4a8de001ec18289d12b58c8144f27ab72cb06",
"installedAt": "2026-02-17T17:28:32.350Z"
}, },
"article-title": { "article-title": {
"source": "github:quartz-community/article-title", "source": "github:quartz-community/article-title",
@ -61,101 +13,23 @@
"commit": "031872e39ba30d8567af63c6a3891b3054432194", "commit": "031872e39ba30d8567af63c6a3891b3054432194",
"installedAt": "2026-02-17T17:28:32.818Z" "installedAt": "2026-02-17T17:28:32.818Z"
}, },
"tag-list": { "backlinks": {
"source": "github:quartz-community/tag-list", "source": "github:quartz-community/backlinks",
"resolved": "https://github.com/quartz-community/tag-list.git", "resolved": "https://github.com/quartz-community/backlinks.git",
"commit": "a0a30b822447261a7a9bcf96d649ca7525eb3ded", "commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d",
"installedAt": "2026-02-17T17:28:33.366Z" "installedAt": "2026-02-17T17:28:30.109Z"
}, },
"page-title": { "breadcrumbs": {
"source": "github:quartz-community/page-title", "source": "github:quartz-community/breadcrumbs",
"resolved": "https://github.com/quartz-community/page-title.git", "resolved": "https://github.com/quartz-community/breadcrumbs.git",
"commit": "d14bffe11830eff6e4489945324a0dd33d994bf6", "commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d",
"installedAt": "2026-02-17T17:28:33.828Z" "installedAt": "2026-02-17T17:28:31.401Z"
}, },
"darkmode": { "canvas-page": {
"source": "github:quartz-community/darkmode", "source": "github:quartz-community/canvas-page",
"resolved": "https://github.com/quartz-community/darkmode.git", "resolved": "https://github.com/quartz-community/canvas-page.git",
"commit": "453e59cf913b2276a5bb6d6bfdc0685304618877", "commit": "18107bc8d759a1860caeda10de336ac66bf7f923",
"installedAt": "2026-02-17T17:28:34.395Z" "installedAt": "2026-02-18T15:02:34.059Z"
},
"reader-mode": {
"source": "github:quartz-community/reader-mode",
"resolved": "https://github.com/quartz-community/reader-mode.git",
"commit": "d362013e0de040dcd8711e7c6bf68b93a0668bae",
"installedAt": "2026-02-17T17:28:34.989Z"
},
"content-meta": {
"source": "github:quartz-community/content-meta",
"resolved": "https://github.com/quartz-community/content-meta.git",
"commit": "be178b7ab4f521f0ca29051676f207a23b706051",
"installedAt": "2026-02-17T17:28:35.416Z"
},
"footer": {
"source": "github:quartz-community/footer",
"resolved": "https://github.com/quartz-community/footer.git",
"commit": "eef4ba0af26ea6097543784fc5cfa6820d6b8250",
"installedAt": "2026-02-17T17:28:35.903Z"
},
"content-page": {
"source": "github:quartz-community/content-page",
"resolved": "https://github.com/quartz-community/content-page.git",
"commit": "9975389d442707d8772aebeaba02676169569c60",
"installedAt": "2026-02-17T17:28:36.353Z"
},
"folder-page": {
"source": "github:quartz-community/folder-page",
"resolved": "https://github.com/quartz-community/folder-page.git",
"commit": "630c26711de6a5cb91ba2b81882f4fad91bbbaeb",
"installedAt": "2026-02-17T17:28:36.806Z"
},
"tag-page": {
"source": "github:quartz-community/tag-page",
"resolved": "https://github.com/quartz-community/tag-page.git",
"commit": "27ad16e609832805b2e11c7836937706a38caec1",
"installedAt": "2026-02-17T17:28:37.377Z"
},
"created-modified-date": {
"source": "github:quartz-community/created-modified-date",
"resolved": "https://github.com/quartz-community/created-modified-date.git",
"commit": "4d976e88da01d9fa0dbdcd11e756b1b3037b3a49",
"installedAt": "2026-02-17T17:28:37.833Z"
},
"syntax-highlighting": {
"source": "github:quartz-community/syntax-highlighting",
"resolved": "https://github.com/quartz-community/syntax-highlighting.git",
"commit": "a3ec90046f68e8d8bbbd275f07f606268cb6e001",
"installedAt": "2026-02-17T17:28:38.257Z"
},
"obsidian-flavored-markdown": {
"source": "github:quartz-community/obsidian-flavored-markdown",
"resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git",
"commit": "a5f45c21cec287e8aa2896b5032fe48962ed9b77",
"installedAt": "2026-02-17T17:28:38.693Z"
},
"github-flavored-markdown": {
"source": "github:quartz-community/github-flavored-markdown",
"resolved": "https://github.com/quartz-community/github-flavored-markdown.git",
"commit": "7e19794a519debc994e2e99c67b223661b0aac0c",
"installedAt": "2026-02-17T17:28:39.147Z"
},
"crawl-links": {
"source": "github:quartz-community/crawl-links",
"resolved": "https://github.com/quartz-community/crawl-links.git",
"commit": "3ca88b4ba1a049cc79b3cf1154b771e4eb8f7549",
"installedAt": "2026-02-17T17:28:39.629Z"
},
"description": {
"source": "github:quartz-community/description",
"resolved": "https://github.com/quartz-community/description.git",
"commit": "82f1c95e53acc7521fa6bf46e9ea20018ee28264",
"installedAt": "2026-02-17T17:28:40.060Z"
},
"hard-line-breaks": {
"source": "github:quartz-community/hard-line-breaks",
"resolved": "https://github.com/quartz-community/hard-line-breaks.git",
"commit": "b14a477db87e6657440a044e6d2fbf9d943472e6",
"installedAt": "2026-02-17T17:28:40.498Z"
}, },
"citations": { "citations": {
"source": "github:quartz-community/citations", "source": "github:quartz-community/citations",
@ -163,47 +37,17 @@
"commit": "b5db4ea6fd6f3fc9c1ce37cd9cdba0ca9e74ecc8", "commit": "b5db4ea6fd6f3fc9c1ce37cd9cdba0ca9e74ecc8",
"installedAt": "2026-02-17T17:28:40.960Z" "installedAt": "2026-02-17T17:28:40.960Z"
}, },
"ox-hugo": {
"source": "github:quartz-community/ox-hugo",
"resolved": "https://github.com/quartz-community/ox-hugo.git",
"commit": "d8fdf7c6a54464e960c6b817147968cf07719086",
"installedAt": "2026-02-17T17:28:41.395Z"
},
"roam": {
"source": "github:quartz-community/roam",
"resolved": "https://github.com/quartz-community/roam.git",
"commit": "f94276c284edb01e6b4136ec6f7683f29123c54c",
"installedAt": "2026-02-17T17:28:41.832Z"
},
"remove-draft": {
"source": "github:quartz-community/remove-draft",
"resolved": "https://github.com/quartz-community/remove-draft.git",
"commit": "61744308cf739f63fe351dea8dffd6b7c434440d",
"installedAt": "2026-02-17T17:28:42.324Z"
},
"explicit-publish": {
"source": "github:quartz-community/explicit-publish",
"resolved": "https://github.com/quartz-community/explicit-publish.git",
"commit": "ba19e6f32393fa5fe788dbf39a9487bc8e73247a",
"installedAt": "2026-02-17T17:28:42.799Z"
},
"alias-redirects": {
"source": "github:quartz-community/alias-redirects",
"resolved": "https://github.com/quartz-community/alias-redirects.git",
"commit": "65897957ab8108d015cd4255d42d84df7377c083",
"installedAt": "2026-02-17T17:28:43.267Z"
},
"cname": { "cname": {
"source": "github:quartz-community/cname", "source": "github:quartz-community/cname",
"resolved": "https://github.com/quartz-community/cname.git", "resolved": "https://github.com/quartz-community/cname.git",
"commit": "752470f04410576a767b48094e926d41eef3eb18", "commit": "752470f04410576a767b48094e926d41eef3eb18",
"installedAt": "2026-02-17T17:28:43.713Z" "installedAt": "2026-02-17T17:28:43.713Z"
}, },
"favicon": { "comments": {
"source": "github:quartz-community/favicon", "source": "github:quartz-community/comments",
"resolved": "https://github.com/quartz-community/favicon.git", "resolved": "https://github.com/quartz-community/comments.git",
"commit": "71cfff28b7a41f28618f574e672b2b399c6b781d", "commit": "bf03ced30c7828c3ae4dae5d313e596983f5d453",
"installedAt": "2026-02-17T17:28:44.365Z" "installedAt": "2026-02-18T19:52:41.699Z"
}, },
"content-index": { "content-index": {
"source": "github:quartz-community/content-index", "source": "github:quartz-community/content-index",
@ -211,17 +55,179 @@
"commit": "2b9c62d659b7f8e9e7cc028a201cae7061224653", "commit": "2b9c62d659b7f8e9e7cc028a201cae7061224653",
"installedAt": "2026-02-17T17:28:44.806Z" "installedAt": "2026-02-17T17:28:44.806Z"
}, },
"content-meta": {
"source": "github:quartz-community/content-meta",
"resolved": "https://github.com/quartz-community/content-meta.git",
"commit": "be178b7ab4f521f0ca29051676f207a23b706051",
"installedAt": "2026-02-17T17:28:35.416Z"
},
"content-page": {
"source": "github:quartz-community/content-page",
"resolved": "https://github.com/quartz-community/content-page.git",
"commit": "738cdc7ab6bc5718e68db4d318b1001a25a1d7d9",
"installedAt": "2026-02-18T15:02:36.681Z"
},
"crawl-links": {
"source": "github:quartz-community/crawl-links",
"resolved": "https://github.com/quartz-community/crawl-links.git",
"commit": "58bdc4878d0e0eae126f8389465ef9c7c2a1fbc9",
"installedAt": "2026-02-18T19:52:43.755Z"
},
"created-modified-date": {
"source": "github:quartz-community/created-modified-date",
"resolved": "https://github.com/quartz-community/created-modified-date.git",
"commit": "0a129541ba64a28ea747fba6f2391611c2b6185c",
"installedAt": "2026-02-18T19:52:44.210Z"
},
"darkmode": {
"source": "github:quartz-community/darkmode",
"resolved": "https://github.com/quartz-community/darkmode.git",
"commit": "453e59cf913b2276a5bb6d6bfdc0685304618877",
"installedAt": "2026-02-17T17:28:34.395Z"
},
"description": {
"source": "github:quartz-community/description",
"resolved": "https://github.com/quartz-community/description.git",
"commit": "82f1c95e53acc7521fa6bf46e9ea20018ee28264",
"installedAt": "2026-02-17T17:28:40.060Z"
},
"explicit-publish": {
"source": "github:quartz-community/explicit-publish",
"resolved": "https://github.com/quartz-community/explicit-publish.git",
"commit": "ba19e6f32393fa5fe788dbf39a9487bc8e73247a",
"installedAt": "2026-02-17T17:28:42.799Z"
},
"explorer": {
"source": "github:quartz-community/explorer",
"resolved": "https://github.com/quartz-community/explorer.git",
"commit": "99f0b2a62f85d60352542ecaf3c516816a401068",
"installedAt": "2026-02-18T15:02:39.752Z"
},
"favicon": {
"source": "github:quartz-community/favicon",
"resolved": "https://github.com/quartz-community/favicon.git",
"commit": "71cfff28b7a41f28618f574e672b2b399c6b781d",
"installedAt": "2026-02-17T17:28:44.365Z"
},
"folder-page": {
"source": "github:quartz-community/folder-page",
"resolved": "https://github.com/quartz-community/folder-page.git",
"commit": "2684accc90cf56ebc21ab21f301fdb8b847f328a",
"installedAt": "2026-02-18T15:02:40.643Z"
},
"footer": {
"source": "github:quartz-community/footer",
"resolved": "https://github.com/quartz-community/footer.git",
"commit": "eef4ba0af26ea6097543784fc5cfa6820d6b8250",
"installedAt": "2026-02-17T17:28:35.903Z"
},
"github-flavored-markdown": {
"source": "github:quartz-community/github-flavored-markdown",
"resolved": "https://github.com/quartz-community/github-flavored-markdown.git",
"commit": "7e19794a519debc994e2e99c67b223661b0aac0c",
"installedAt": "2026-02-17T17:28:39.147Z"
},
"graph": {
"source": "github:quartz-community/graph",
"resolved": "https://github.com/quartz-community/graph.git",
"commit": "b908529c04044e907680cc9b9f37854ae1a14000",
"installedAt": "2026-02-18T15:02:42.108Z"
},
"hard-line-breaks": {
"source": "github:quartz-community/hard-line-breaks",
"resolved": "https://github.com/quartz-community/hard-line-breaks.git",
"commit": "b14a477db87e6657440a044e6d2fbf9d943472e6",
"installedAt": "2026-02-17T17:28:40.498Z"
},
"latex": {
"source": "github:quartz-community/latex",
"resolved": "https://github.com/quartz-community/latex.git",
"commit": "6660addddc5af95437d225ebf3ec2cfecf4527e7",
"installedAt": "2026-02-18T19:52:49.775Z"
},
"obsidian-flavored-markdown": {
"source": "github:quartz-community/obsidian-flavored-markdown",
"resolved": "https://github.com/quartz-community/obsidian-flavored-markdown.git",
"commit": "a5f45c21cec287e8aa2896b5032fe48962ed9b77",
"installedAt": "2026-02-17T17:28:38.693Z"
},
"og-image": { "og-image": {
"source": "github:quartz-community/og-image", "source": "github:quartz-community/og-image",
"resolved": "https://github.com/quartz-community/og-image.git", "resolved": "https://github.com/quartz-community/og-image.git",
"commit": "9a00809fccb0e1e4b7db4ee6e6eed002952396f4", "commit": "9a00809fccb0e1e4b7db4ee6e6eed002952396f4",
"installedAt": "2026-02-17T17:28:45.533Z" "installedAt": "2026-02-17T17:28:45.533Z"
}, },
"canvas-page": { "ox-hugo": {
"source": "github:quartz-community/canvas-page", "source": "github:quartz-community/ox-hugo",
"resolved": "https://github.com/quartz-community/canvas-page.git", "resolved": "https://github.com/quartz-community/ox-hugo.git",
"commit": "683c2da6bb068d3e596cdb3bfcca68007829cd17", "commit": "d8fdf7c6a54464e960c6b817147968cf07719086",
"installedAt": "2026-02-17T17:28:46.012Z" "installedAt": "2026-02-17T17:28:41.395Z"
},
"page-title": {
"source": "github:quartz-community/page-title",
"resolved": "https://github.com/quartz-community/page-title.git",
"commit": "d14bffe11830eff6e4489945324a0dd33d994bf6",
"installedAt": "2026-02-17T17:28:33.828Z"
},
"reader-mode": {
"source": "github:quartz-community/reader-mode",
"resolved": "https://github.com/quartz-community/reader-mode.git",
"commit": "d362013e0de040dcd8711e7c6bf68b93a0668bae",
"installedAt": "2026-02-17T17:28:34.989Z"
},
"recent-notes": {
"source": "github:quartz-community/recent-notes",
"resolved": "https://github.com/quartz-community/recent-notes.git",
"commit": "96d5df9fc6d7e0e8bd3a90856faaa08d2ac3441b",
"installedAt": "2026-02-17T17:28:31.827Z"
},
"remove-draft": {
"source": "github:quartz-community/remove-draft",
"resolved": "https://github.com/quartz-community/remove-draft.git",
"commit": "61744308cf739f63fe351dea8dffd6b7c434440d",
"installedAt": "2026-02-17T17:28:42.324Z"
},
"roam": {
"source": "github:quartz-community/roam",
"resolved": "https://github.com/quartz-community/roam.git",
"commit": "f94276c284edb01e6b4136ec6f7683f29123c54c",
"installedAt": "2026-02-17T17:28:41.832Z"
},
"search": {
"source": "github:quartz-community/search",
"resolved": "https://github.com/quartz-community/search.git",
"commit": "5d206baacbad17e99925f61c6263e98a494258d1",
"installedAt": "2026-02-17T17:28:29.442Z"
},
"spacer": {
"source": "github:quartz-community/spacer",
"resolved": "https://github.com/quartz-community/spacer.git",
"commit": "b972767017536c879d92ac00f4732fc8c993d560",
"installedAt": "2026-02-17T20:39:12.603Z"
},
"syntax-highlighting": {
"source": "github:quartz-community/syntax-highlighting",
"resolved": "https://github.com/quartz-community/syntax-highlighting.git",
"commit": "a3ec90046f68e8d8bbbd275f07f606268cb6e001",
"installedAt": "2026-02-17T17:28:38.257Z"
},
"table-of-contents": {
"source": "github:quartz-community/table-of-contents",
"resolved": "https://github.com/quartz-community/table-of-contents.git",
"commit": "38e7f0198114e4466cb39ea0c2ebb01e46847d39",
"installedAt": "2026-02-17T17:28:30.541Z"
},
"tag-list": {
"source": "github:quartz-community/tag-list",
"resolved": "https://github.com/quartz-community/tag-list.git",
"commit": "a0a30b822447261a7a9bcf96d649ca7525eb3ded",
"installedAt": "2026-02-17T17:28:33.366Z"
},
"tag-page": {
"source": "github:quartz-community/tag-page",
"resolved": "https://github.com/quartz-community/tag-page.git",
"commit": "45e16343dd6bb85174237fc66f81e835244e7935",
"installedAt": "2026-02-18T15:02:50.019Z"
} }
} }
} }

View File

@ -32,6 +32,47 @@ import {
} from "./cli/args.js" } from "./cli/args.js"
import { version } from "./cli/constants.js" import { version } from "./cli/constants.js"
async function launchTui() {
const { pathToFileURL } = await import("url")
const { join } = await import("path")
const { existsSync } = await import("fs")
const { spawn } = await import("child_process")
const tuiPath = join(process.cwd(), "quartz", "cli", "tui", "dist", "App.mjs")
if (!existsSync(tuiPath)) {
console.log("TUI not built yet. Building...")
const buildScript = pathToFileURL(join(process.cwd(), "quartz", "cli", "build-tui.mjs")).href
await import(buildScript)
console.log("TUI built successfully.")
}
// OpenTUI requires Bun runtime (uses bun:ffi for Zig renderer)
return new Promise((resolve, reject) => {
const child = spawn("bun", ["run", tuiPath], {
stdio: "inherit",
cwd: process.cwd(),
})
child.on("error", (err) => {
if (err.code === "ENOENT") {
console.error(
"Error: Bun runtime not found. The TUI requires Bun to run.\n" +
"Install Bun: https://bun.sh/docs/installation",
)
}
reject(err)
})
child.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`TUI exited with code ${code}`))
}
})
})
}
yargs(hideBin(process.argv)) yargs(hideBin(process.argv))
.scriptName("quartz") .scriptName("quartz")
.version(version) .version(version)
@ -59,8 +100,11 @@ yargs(hideBin(process.argv))
.command("migrate", "Migrate old config to quartz.plugins.json", CommonArgv, async () => { .command("migrate", "Migrate old config to quartz.plugins.json", CommonArgv, async () => {
await handleMigrate() await handleMigrate()
}) })
.command("tui", "Launch interactive plugin manager", CommonArgv, async () => {
await launchTui()
})
.command( .command(
"plugin <subcommand>", "plugin [subcommand]",
"Manage Quartz plugins", "Manage Quartz plugins",
(yargs) => { (yargs) => {
return yargs return yargs
@ -125,10 +169,11 @@ yargs(hideBin(process.argv))
.command("check", "Check for plugin updates", CommonArgv, async () => { .command("check", "Check for plugin updates", CommonArgv, async () => {
await handlePluginCheck() await handlePluginCheck()
}) })
.demandCommand(1, "Please specify a plugin subcommand") .demandCommand(0, "")
}, },
async () => { async (argv) => {
// This handler is called when no subcommand is provided if (!argv._.includes("plugin") || argv._.length > 1) return
await launchTui()
}, },
) )
.showHelpOnFail(false) .showHelpOnFail(false)

21
quartz/cli/build-tui.mjs Normal file
View File

@ -0,0 +1,21 @@
#!/usr/bin/env node
import esbuild from "esbuild"
import path from "path"
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tuiDir = path.join(__dirname, "tui")
await esbuild.build({
entryPoints: [path.join(tuiDir, "App.tsx")],
outdir: path.join(tuiDir, "dist"),
bundle: true,
platform: "node",
format: "esm",
jsx: "automatic",
jsxImportSource: "@opentui/react",
packages: "external",
sourcemap: true,
target: "esnext",
outExtension: { ".js": ".mjs" },
})

280
quartz/cli/plugin-data.js Normal file
View File

@ -0,0 +1,280 @@
import fs from "fs"
import path from "path"
import { execSync } from "child_process"
const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json")
const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins")
const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
const DEFAULT_PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.default.json")
export function readPluginsJson() {
if (!fs.existsSync(PLUGINS_JSON_PATH)) return null
try {
return JSON.parse(fs.readFileSync(PLUGINS_JSON_PATH, "utf-8"))
} catch {
return null
}
}
export function writePluginsJson(data) {
fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(data, null, 2) + "\n")
}
export function readDefaultPluginsJson() {
if (!fs.existsSync(DEFAULT_PLUGINS_JSON_PATH)) return null
try {
return JSON.parse(fs.readFileSync(DEFAULT_PLUGINS_JSON_PATH, "utf-8"))
} catch {
return null
}
}
export function readLockfile() {
if (!fs.existsSync(LOCKFILE_PATH)) return null
try {
return JSON.parse(fs.readFileSync(LOCKFILE_PATH, "utf-8"))
} catch {
return null
}
}
export function writeLockfile(lockfile) {
if (lockfile.plugins) {
const sorted = {}
for (const key of Object.keys(lockfile.plugins).sort()) {
sorted[key] = lockfile.plugins[key]
}
lockfile = { ...lockfile, plugins: sorted }
}
fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2) + "\n")
}
export function extractPluginName(source) {
if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "")
const [repoPath] = withoutPrefix.split("#")
const parts = repoPath.split("/")
return parts[parts.length - 1]
}
if (source.startsWith("git+") || source.startsWith("https://")) {
const url = source.replace("git+", "")
const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
return match?.[1] ?? source
}
return source
}
export function readManifestFromPackageJson(pluginDir) {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return null
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
return pkg.quartz ?? null
} catch {
return null
}
}
export function parseGitSource(source) {
if (source.startsWith("github:")) {
const [repoPath, ref] = source.replace("github:", "").split("#")
const [owner, repo] = repoPath.split("/")
return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref }
}
if (source.startsWith("git+")) {
const url = source.replace("git+", "")
const name = path.basename(url, ".git")
return { name, url }
}
if (source.startsWith("https://")) {
const name = path.basename(source, ".git")
return { name, url: source }
}
throw new Error(`Cannot parse plugin source: ${source}`)
}
export function getGitCommit(pluginDir) {
try {
return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim()
} catch {
return "unknown"
}
}
export function getPluginDir(name) {
return path.join(PLUGINS_DIR, name)
}
export function pluginDirExists(name) {
return fs.existsSync(path.join(PLUGINS_DIR, name))
}
export function ensurePluginsDir() {
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
}
/**
* Merges quartz.plugins.json, quartz.lock.json, and on-disk manifest data
* into enriched plugin entries with: name, displayName, source, enabled,
* options, order, layout, category, installed, locked, manifest,
* currentCommit, modified.
*/
export function getEnrichedPlugins() {
const pluginsJson = readPluginsJson()
const lockfile = readLockfile()
if (!pluginsJson?.plugins) return []
return pluginsJson.plugins.map((entry, index) => {
const name = extractPluginName(entry.source)
const pluginDir = path.join(PLUGINS_DIR, name)
const installed = fs.existsSync(pluginDir)
const locked = lockfile?.plugins?.[name] ?? null
const manifest = installed ? readManifestFromPackageJson(pluginDir) : null
const currentCommit = installed ? getGitCommit(pluginDir) : null
const modified = locked && currentCommit ? currentCommit !== locked.commit : false
return {
index,
name,
displayName: manifest?.displayName ?? name,
source: entry.source,
enabled: entry.enabled ?? true,
options: entry.options ?? {},
order: entry.order ?? 50,
layout: entry.layout ?? null,
category: manifest?.category ?? "unknown",
installed,
locked,
manifest,
currentCommit,
modified,
}
})
}
export function getLayoutConfig() {
const pluginsJson = readPluginsJson()
return pluginsJson?.layout ?? null
}
export function getGlobalConfig() {
const pluginsJson = readPluginsJson()
return pluginsJson?.configuration ?? null
}
export function updatePluginEntry(index, updates) {
const json = readPluginsJson()
if (!json?.plugins?.[index]) return false
Object.assign(json.plugins[index], updates)
writePluginsJson(json)
return true
}
export function updateGlobalConfig(updates) {
const json = readPluginsJson()
if (!json) return false
json.configuration = { ...json.configuration, ...updates }
writePluginsJson(json)
return true
}
export function updateLayoutConfig(layout) {
const json = readPluginsJson()
if (!json) return false
json.layout = layout
writePluginsJson(json)
return true
}
export function reorderPlugin(fromIndex, toIndex) {
const json = readPluginsJson()
if (!json?.plugins) return false
const [moved] = json.plugins.splice(fromIndex, 1)
json.plugins.splice(toIndex, 0, moved)
writePluginsJson(json)
return true
}
export function removePluginEntry(index) {
const json = readPluginsJson()
if (!json?.plugins?.[index]) return false
json.plugins.splice(index, 1)
writePluginsJson(json)
return true
}
export function addPluginEntry(entry) {
const json = readPluginsJson()
if (!json) return false
if (!json.plugins) json.plugins = []
json.plugins.push(entry)
writePluginsJson(json)
return true
}
export function configExists() {
return fs.existsSync(PLUGINS_JSON_PATH)
}
export function createConfigFromDefault() {
const defaultJson = readDefaultPluginsJson()
if (!defaultJson) {
// No default available — create minimal config
const minimal = {
$schema: "./quartz/plugins/quartz-plugins.schema.json",
configuration: {
pageTitle: "Quartz",
enableSPA: true,
enablePopovers: true,
analytics: { provider: "plausible" },
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
theme: {
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
},
},
},
plugins: [],
layout: { groups: {}, byPageType: {} },
}
writePluginsJson(minimal)
return minimal
}
writePluginsJson(defaultJson)
return defaultJson
}
export { LOCKFILE_PATH, PLUGINS_DIR, PLUGINS_JSON_PATH, DEFAULT_PLUGINS_JSON_PATH }

View File

@ -2,51 +2,21 @@ import fs from "fs"
import path from "path" import path from "path"
import { execSync } from "child_process" import { execSync } from "child_process"
import { styleText } from "util" import { styleText } from "util"
import {
readPluginsJson,
writePluginsJson,
readLockfile,
writeLockfile,
extractPluginName,
readManifestFromPackageJson,
parseGitSource,
getGitCommit,
PLUGINS_DIR,
LOCKFILE_PATH,
} from "./plugin-data.js"
const LOCKFILE_PATH = path.join(process.cwd(), "quartz.lock.json")
const PLUGINS_DIR = path.join(process.cwd(), ".quartz", "plugins")
const PLUGINS_JSON_PATH = path.join(process.cwd(), "quartz.plugins.json")
const INTERNAL_EXPORTS = new Set(["manifest", "default"]) const INTERNAL_EXPORTS = new Set(["manifest", "default"])
function readPluginsJson() {
if (!fs.existsSync(PLUGINS_JSON_PATH)) return null
try {
return JSON.parse(fs.readFileSync(PLUGINS_JSON_PATH, "utf-8"))
} catch {
return null
}
}
function writePluginsJson(data) {
fs.writeFileSync(PLUGINS_JSON_PATH, JSON.stringify(data, null, 2) + "\n")
}
function extractPluginName(source) {
if (source.startsWith("github:")) {
const withoutPrefix = source.replace("github:", "")
const [repoPath] = withoutPrefix.split("#")
const parts = repoPath.split("/")
return parts[parts.length - 1]
}
if (source.startsWith("git+") || source.startsWith("https://")) {
const url = source.replace("git+", "")
const match = url.match(/\/([^/]+?)(?:\.git)?(?:#|$)/)
return match?.[1] ?? source
}
return source
}
function readManifestFromPackageJson(pluginDir) {
const pkgPath = path.join(pluginDir, "package.json")
if (!fs.existsSync(pkgPath)) return null
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
return pkg.quartz ?? null
} catch {
return null
}
}
function buildPlugin(pluginDir, name) { function buildPlugin(pluginDir, name) {
try { try {
console.log(styleText("cyan", `${name}: installing dependencies...`)) console.log(styleText("cyan", `${name}: installing dependencies...`))
@ -128,48 +98,6 @@ async function regeneratePluginIndex() {
fs.writeFileSync(indexPath, indexContent) fs.writeFileSync(indexPath, indexContent)
} }
function readLockfile() {
if (!fs.existsSync(LOCKFILE_PATH)) {
return null
}
try {
const content = fs.readFileSync(LOCKFILE_PATH, "utf-8")
return JSON.parse(content)
} catch {
return null
}
}
function writeLockfile(lockfile) {
fs.writeFileSync(LOCKFILE_PATH, JSON.stringify(lockfile, null, 2))
}
function parseGitSource(source) {
if (source.startsWith("github:")) {
const [repoPath, ref] = source.replace("github:", "").split("#")
const [owner, repo] = repoPath.split("/")
return { name: repo, url: `https://github.com/${owner}/${repo}.git`, ref }
}
if (source.startsWith("git+")) {
const url = source.replace("git+", "")
const name = path.basename(url, ".git")
return { name, url }
}
if (source.startsWith("https://")) {
const name = path.basename(source, ".git")
return { name, url: source }
}
throw new Error(`Cannot parse plugin source: ${source}`)
}
function getGitCommit(pluginDir) {
try {
return execSync("git rev-parse HEAD", { cwd: pluginDir, encoding: "utf-8" }).trim()
} catch {
return "unknown"
}
}
export async function handlePluginInstall() { export async function handlePluginInstall() {
const lockfile = readLockfile() const lockfile = readLockfile()

114
quartz/cli/tui/App.tsx Normal file
View File

@ -0,0 +1,114 @@
import { useCallback, useState } from "react"
import { createCliRenderer } from "@opentui/core"
import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
import { Notification, type NotificationMessage } from "./components/Notification.js"
import { SetupWizard } from "./components/SetupWizard.js"
import { StatusBar } from "./components/StatusBar.js"
import { LayoutPanel } from "./panels/LayoutPanel.js"
import { PluginsPanel } from "./panels/PluginsPanel.js"
import { SettingsPanel } from "./panels/SettingsPanel.js"
import { version } from "../constants.js"
import { configExists } from "../plugin-data.js"
const TABS = ["Plugins", "Layout", "Settings"] as const
type Tab = (typeof TABS)[number]
export function App() {
const renderer = useRenderer()
const { height: rows } = useTerminalDimensions()
const [hasConfig, setHasConfig] = useState(() => configExists())
const [activeTab, setActiveTab] = useState<Tab>("Plugins")
const [notification, setNotification] = useState<NotificationMessage | null>(null)
const [panelFocused, setPanelFocused] = useState(false)
const notify = useCallback((message: string, type: "success" | "error" | "info" = "info") => {
setNotification({ message, type })
setTimeout(() => setNotification(null), 3000)
}, [])
useKeyboard((event) => {
if (panelFocused || !hasConfig) return
if (event.name !== "q") return
renderer.destroy()
process.exit(0)
})
useKeyboard((event) => {
if (!hasConfig || panelFocused) return
if (event.name !== "tab") return
const currentIndex = TABS.indexOf(activeTab)
const next = event.shift
? (currentIndex - 1 + TABS.length) % TABS.length
: (currentIndex + 1) % TABS.length
setActiveTab(TABS[next])
})
if (!hasConfig) {
return (
<box flexDirection="column" height={rows}>
<box justifyContent="center" paddingY={0}>
<text>
<span fg="green">
<strong>{` Quartz v${version} Plugin Manager `}</strong>
</span>
</text>
</box>
<box flexDirection="column" flexGrow={1} justifyContent="center">
<SetupWizard
onComplete={() => {
setHasConfig(true)
notify("Configuration created", "success")
}}
/>
</box>
{notification && <Notification message={notification} />}
</box>
)
}
return (
<box flexDirection="column" height={rows}>
<box justifyContent="center" paddingY={0}>
<text>
<span fg="green">
<strong>{` Quartz v${version} Plugin Manager `}</strong>
</span>
</text>
</box>
<box flexDirection="row" paddingX={1} gap={2}>
{TABS.map((tab) => (
<text key={tab}>
{tab === activeTab ? (
<span fg="cyan">
<strong>[ {tab} ]</strong>
</span>
) : (
<span fg="#888888">{` ${tab} `}</span>
)}
</text>
))}
</box>
<box flexDirection="column" flexGrow={1} paddingX={1} overflow="hidden">
{activeTab === "Plugins" && (
<PluginsPanel notify={notify} onFocusChange={setPanelFocused} />
)}
{activeTab === "Layout" && <LayoutPanel notify={notify} onFocusChange={setPanelFocused} />}
{activeTab === "Settings" && (
<SettingsPanel notify={notify} onFocusChange={setPanelFocused} />
)}
</box>
{notification && <Notification message={notification} />}
<StatusBar activeTab={activeTab} />
</box>
)
}
const renderer = await createCliRenderer({ exitOnCtrlC: true })
createRoot(renderer).render(<App />)

View File

@ -0,0 +1,572 @@
import fs from "fs"
import path from "path"
import { execFile } from "child_process"
import { promisify } from "util"
import {
readPluginsJson,
writePluginsJson,
readLockfile,
writeLockfile,
extractPluginName,
readManifestFromPackageJson,
parseGitSource,
PLUGINS_DIR,
} from "../plugin-data.js"
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
interface LockfileEntry {
source: string
resolved: string
commit: string
installedAt: string
}
interface Lockfile {
version: string
plugins: Record<string, LockfileEntry>
}
interface PluginsJsonEntry {
source: string
enabled?: boolean
options?: Record<string, unknown>
order?: number
layout?: {
position: string
priority?: number
display?: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
}
}
interface PluginsJson {
plugins?: PluginsJsonEntry[]
}
interface PluginManifestComponent {
defaultPosition?: string
defaultPriority?: number
}
interface PluginManifest {
defaultEnabled?: boolean
defaultOptions?: Record<string, unknown>
defaultOrder?: number
components?: Record<string, PluginManifestComponent>
}
const INTERNAL_EXPORTS = new Set(["manifest", "default"])
const execFileAsync = promisify(execFile)
function report(
onProgress: ProgressCallback | undefined,
message: string,
type: OperationResultType,
) {
onProgress?.(message, type)
}
type OperationResultType = "info" | "success" | "error" | "warning"
function shortCommit(commit: string) {
return commit.slice(0, 7)
}
async function runGit(args: string[], cwd?: string, timeout = 60000) {
const { stdout } = await execFileAsync("git", args, { cwd, timeout, encoding: "utf-8" })
return stdout.trim()
}
async function runNpm(args: string[], cwd: string, timeout = 300000) {
await execFileAsync("npm", args, { cwd, timeout, encoding: "utf-8" })
}
async function getGitCommitAsync(pluginDir: string) {
try {
const commit = await runGit(["rev-parse", "HEAD"], pluginDir)
return commit || "unknown"
} catch {
return "unknown"
}
}
async function buildPlugin(
pluginDir: string,
name: string,
onProgress?: ProgressCallback,
): Promise<boolean> {
try {
report(onProgress, `${name}: installing dependencies...`, "info")
await runNpm(["install"], pluginDir)
report(onProgress, `${name}: building...`, "info")
await runNpm(["run", "build"], pluginDir)
return true
} catch (error) {
report(onProgress, `${name}: build failed`, "error")
return false
}
}
function needsBuild(pluginDir: string) {
const distDir = path.join(pluginDir, "dist")
return !fs.existsSync(distDir)
}
function parseExportsFromDts(content: string) {
const exports: string[] = []
const exportMatches = content.matchAll(/export\s*{\s*([^}]+)\s*}(?:\s*from\s*['"]([^'"]+)['"])?/g)
for (const match of exportMatches) {
const fromModule = match[2]
if (fromModule?.startsWith("@")) continue
const names = match[1]
.split(",")
.map((n) => n.trim())
.filter(Boolean)
for (const name of names) {
const cleanName = name.split(" as ").pop()?.trim() || name.trim()
if (cleanName && !cleanName.startsWith("_") && !INTERNAL_EXPORTS.has(cleanName)) {
const finalName = cleanName.replace(/^type\s+/, "")
if (name.includes("type ")) {
exports.push(`type ${finalName}`)
} else {
exports.push(finalName)
}
}
}
}
return exports
}
async function regeneratePluginIndex() {
if (!fs.existsSync(PLUGINS_DIR)) return
const entries = await fs.promises.readdir(PLUGINS_DIR, { withFileTypes: true })
const plugins = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name)
const exports: string[] = []
for (const pluginName of plugins) {
const pluginDir = path.join(PLUGINS_DIR, pluginName)
const distIndex = path.join(pluginDir, "dist", "index.d.ts")
if (!fs.existsSync(distIndex)) continue
const dtsContent = await fs.promises.readFile(distIndex, "utf-8")
const exportedNames = parseExportsFromDts(dtsContent)
if (exportedNames.length > 0) {
const namedExports = exportedNames.filter((e) => !e.startsWith("type "))
const typeExports = exportedNames.filter((e) => e.startsWith("type ")).map((e) => e.slice(5))
if (namedExports.length > 0) {
exports.push(`export { ${namedExports.join(", ")} } from "./${pluginName}"`)
}
if (typeExports.length > 0) {
exports.push(`export type { ${typeExports.join(", ")} } from "./${pluginName}"`)
}
}
}
const indexContent = exports.join("\n") + "\n"
const indexPath = path.join(PLUGINS_DIR, "index.ts")
await fs.promises.writeFile(indexPath, indexContent)
}
export async function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No quartz.lock.json found. Run 'npx quartz plugin add <repo>' first."
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
report(onProgress, "→ Installing plugins from lockfile...", "info")
let installed = 0
let failed = 0
const errors: string[] = []
const pluginsToBuild: Array<{ name: string; pluginDir: string }> = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
try {
const currentCommit = await getGitCommitAsync(pluginDir)
if (currentCommit === entry.commit && !needsBuild(pluginDir)) {
report(onProgress, `${name}@${shortCommit(entry.commit)} already installed`, "info")
installed++
continue
}
if (currentCommit !== entry.commit) {
report(onProgress, `${name}: updating to ${shortCommit(entry.commit)}...`, "info")
await runGit(["fetch", "--depth", "1", "origin"], pluginDir)
await runGit(["reset", "--hard", entry.commit], pluginDir)
}
pluginsToBuild.push({ name, pluginDir })
installed++
} catch (error) {
const message = `${name}: failed to update`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
} else {
try {
report(onProgress, `${name}: cloning...`, "info")
await runGit(["clone", "--depth", "1", entry.resolved, pluginDir])
if (entry.commit !== "unknown") {
await runGit(["fetch", "--depth", "1", "origin", entry.commit], pluginDir)
await runGit(["checkout", entry.commit], pluginDir)
}
report(onProgress, `${name}@${shortCommit(entry.commit)}`, "success")
pluginsToBuild.push({ name, pluginDir })
installed++
} catch (error) {
const message = `${name}: failed to clone`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
}
if (pluginsToBuild.length > 0) {
report(onProgress, "→ Building plugins...", "info")
for (const { name, pluginDir } of pluginsToBuild) {
if (!(await buildPlugin(pluginDir, name, onProgress))) {
failed++
installed--
} else {
report(onProgress, `${name} built`, "success")
}
}
}
await regeneratePluginIndex()
if (failed === 0) {
report(onProgress, `✓ Installed ${installed} plugin(s)`, "success")
} else {
report(onProgress, `⚠ Installed ${installed} plugin(s), ${failed} failed`, "warning")
}
return { success: failed === 0, installed, failed, errors }
}
export async function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
let lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
lockfile = { version: "1.0.0", plugins: {} }
}
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
const addedPlugins: Array<{ name: string; pluginDir: string; source: string }> = []
const errors: string[] = []
let failed = 0
for (const source of sources) {
try {
const { name, url, ref } = parseGitSource(source)
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
report(onProgress, `${name} already exists. Use 'update' to refresh.`, "warning")
continue
}
report(onProgress, `→ Adding ${name} from ${url}...`, "info")
if (ref) {
await runGit(["clone", "--depth", "1", "--branch", ref, url, pluginDir])
} else {
await runGit(["clone", "--depth", "1", url, pluginDir])
}
const commit = await getGitCommitAsync(pluginDir)
lockfile.plugins[name] = {
source,
resolved: url,
commit,
installedAt: new Date().toISOString(),
}
addedPlugins.push({ name, pluginDir, source })
report(onProgress, `✓ Added ${name}@${shortCommit(commit)}`, "success")
} catch (error) {
const message = `✗ Failed to add ${source}: ${error instanceof Error ? error.message : error}`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (addedPlugins.length > 0) {
report(onProgress, "→ Building plugins...", "info")
for (const { name, pluginDir } of addedPlugins) {
if (await buildPlugin(pluginDir, name, onProgress)) {
report(onProgress, `${name} built`, "success")
}
}
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson() as PluginsJson | null
if (pluginsJson?.plugins) {
for (const { pluginDir, source } of addedPlugins) {
const manifest = readManifestFromPackageJson(pluginDir) as PluginManifest | null
const newEntry: PluginsJsonEntry = {
source,
enabled: manifest?.defaultEnabled ?? true,
options: manifest?.defaultOptions ?? {},
order: manifest?.defaultOrder ?? 50,
}
if (manifest?.components) {
const firstComponentKey = Object.keys(manifest.components)[0]
const comp = manifest.components[firstComponentKey]
if (comp?.defaultPosition) {
newEntry.layout = {
position: comp.defaultPosition,
priority: comp.defaultPriority ?? 50,
display: "all",
}
}
}
pluginsJson.plugins.push(newEntry)
}
writePluginsJson(pluginsJson as Record<string, unknown>)
}
report(onProgress, "Updated quartz.lock.json", "info")
return { success: failed === 0, installed: addedPlugins.length, failed, errors }
}
export async function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No plugins installed"
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
let removed = false
let removedCount = 0
const errors: string[] = []
for (const name of names) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (!lockfile.plugins[name] && !fs.existsSync(pluginDir)) {
report(onProgress, `${name} is not installed`, "warning")
continue
}
report(onProgress, `→ Removing ${name}...`, "info")
try {
if (fs.existsSync(pluginDir)) {
fs.rmSync(pluginDir, { recursive: true })
}
delete lockfile.plugins[name]
report(onProgress, `✓ Removed ${name}`, "success")
removed = true
removedCount++
} catch (error) {
report(onProgress, `✗ Failed to remove ${name}`, "error")
errors.push(error instanceof Error ? error.message : String(error))
}
}
if (removed) {
await regeneratePluginIndex()
}
writeLockfile(lockfile)
const pluginsJson = readPluginsJson() as PluginsJson | null
if (pluginsJson?.plugins) {
pluginsJson.plugins = pluginsJson.plugins.filter(
(plugin) =>
!names.includes(extractPluginName(plugin.source)) && !names.includes(plugin.source),
)
writePluginsJson(pluginsJson as Record<string, unknown>)
}
report(onProgress, "Updated quartz.lock.json", "info")
return { success: errors.length === 0, installed: removedCount, failed: errors.length, errors }
}
export async function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "⚠ No plugins installed"
report(onProgress, message, "warning")
return { success: false, errors: [message] }
}
const pluginsToUpdate = names ?? Object.keys(lockfile.plugins)
const updatedPlugins: Array<{ name: string; pluginDir: string }> = []
const errors: string[] = []
let failed = 0
for (const name of pluginsToUpdate) {
const entry = lockfile.plugins[name]
if (!entry) {
report(onProgress, `${name} is not installed`, "warning")
continue
}
const pluginDir = path.join(PLUGINS_DIR, name)
if (!fs.existsSync(pluginDir)) {
report(onProgress, `${name} directory missing. Run 'npx quartz plugin install'.`, "warning")
continue
}
try {
report(onProgress, `→ Updating ${name}...`, "info")
await runGit(["fetch", "--depth", "1", "origin"], pluginDir)
await runGit(["reset", "--hard", "origin/HEAD"], pluginDir)
const newCommit = await getGitCommitAsync(pluginDir)
if (newCommit !== entry.commit) {
entry.commit = newCommit
entry.installedAt = new Date().toISOString()
updatedPlugins.push({ name, pluginDir })
report(onProgress, `✓ Updated ${name} to ${shortCommit(newCommit)}`, "success")
} else {
report(onProgress, `${name} already up to date`, "info")
}
} catch (error) {
const message = `✗ Failed to update ${name}: ${error instanceof Error ? error.message : error}`
report(onProgress, message, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (updatedPlugins.length > 0) {
report(onProgress, "→ Rebuilding updated plugins...", "info")
for (const { name, pluginDir } of updatedPlugins) {
if (await buildPlugin(pluginDir, name, onProgress)) {
report(onProgress, `${name} rebuilt`, "success")
}
}
await regeneratePluginIndex()
}
writeLockfile(lockfile)
report(onProgress, "Updated quartz.lock.json", "info")
return {
success: failed === 0,
updated: updatedPlugins.map((plugin) => plugin.name),
failed,
errors,
}
}
export async function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult> {
const lockfile = readLockfile() as Lockfile | null
if (!lockfile) {
const message = "✗ No quartz.lock.json found. Cannot restore."
report(onProgress, message, "error")
report(
onProgress,
"Run 'npx quartz plugin add <repo>' to install plugins from scratch.",
"info",
)
return { success: false, errors: [message] }
}
report(onProgress, "→ Restoring plugins from lockfile...", "info")
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true })
}
let installed = 0
let failed = 0
const restoredPlugins: Array<{ name: string; pluginDir: string }> = []
const errors: string[] = []
for (const [name, entry] of Object.entries(lockfile.plugins)) {
const pluginDir = path.join(PLUGINS_DIR, name)
if (fs.existsSync(pluginDir)) {
report(onProgress, `${name}: directory exists, skipping`, "warning")
continue
}
try {
report(
onProgress,
`${name}: cloning ${entry.resolved}@${shortCommit(entry.commit)}...`,
"info",
)
await runGit(["clone", entry.resolved, pluginDir])
await runGit(["checkout", entry.commit], pluginDir)
report(onProgress, `${name} restored`, "success")
restoredPlugins.push({ name, pluginDir })
installed++
} catch (error) {
report(onProgress, `${name}: failed to restore`, "error")
errors.push(error instanceof Error ? error.message : String(error))
failed++
}
}
if (restoredPlugins.length > 0) {
report(onProgress, "→ Building restored plugins...", "info")
for (const { name, pluginDir } of restoredPlugins) {
if (!(await buildPlugin(pluginDir, name, onProgress))) {
failed++
installed--
} else {
report(onProgress, `${name} built`, "success")
}
}
await regeneratePluginIndex()
}
if (failed === 0) {
report(onProgress, `✓ Restored ${installed} plugin(s)`, "success")
} else {
report(onProgress, `⚠ Restored ${installed} plugin(s), ${failed} failed`, "warning")
}
return { success: failed === 0, installed, failed, errors }
}

175
quartz/cli/tui/cli-modules.d.ts vendored Normal file
View File

@ -0,0 +1,175 @@
declare module "../plugin-data.js" {
export function configExists(): boolean
export function createConfigFromDefault(): Record<string, unknown>
}
declare module "@opentui/core" {
export type SelectOption = { name: string; description: string; value?: any }
export type TabSelectOption = { name: string; description: string; value?: any }
export interface CliRendererOptions {
exitOnCtrlC?: boolean
useAlternateScreen?: boolean
}
export function createCliRenderer(options?: CliRendererOptions): Promise<unknown>
}
declare module "@opentui/react" {
export function createRoot(renderer: unknown): { render(element: unknown): void }
export function useKeyboard(
handler: (event: {
name: string
shift?: boolean
ctrl?: boolean
meta?: boolean
eventType?: string
repeated?: boolean
}) => void,
): void
export function useOnResize(callback: (width: number, height: number) => void): void
export function useTimeline(options?: {
duration?: number
loop?: boolean
autoplay?: boolean
}): unknown
export function useRenderer(): { destroy(): void; console: { show(): void } }
export function useTerminalDimensions(): { width: number; height: number }
}
declare module "../../plugin-data.js" {
export function readPluginsJson(): Record<string, unknown> | null
export function writePluginsJson(data: Record<string, unknown>): void
export function readDefaultPluginsJson(): Record<string, unknown> | null
export function readLockfile(): Record<string, unknown> | null
export function writeLockfile(lockfile: Record<string, unknown>): void
export function extractPluginName(source: string): string
export function readManifestFromPackageJson(pluginDir: string): Record<string, unknown> | null
export function parseGitSource(source: string): { name: string; url: string; ref?: string }
export function getGitCommit(pluginDir: string): string
export function getPluginDir(name: string): string
export function pluginDirExists(name: string): boolean
export function ensurePluginsDir(): void
export function getEnrichedPlugins(): Array<{
index: number
name: string
displayName: string
source: string
enabled: boolean
options: Record<string, unknown>
order: number
layout: {
position: string
priority: number
display: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
} | null
category: string | string[]
installed: boolean
locked: {
source: string
resolved: string
commit: string
installedAt: string
} | null
manifest: Record<string, unknown> | null
currentCommit: string | null
modified: boolean
}>
export function getLayoutConfig(): Record<string, unknown> | null
export function getGlobalConfig(): Record<string, unknown> | null
export function updatePluginEntry(index: number, updates: Record<string, unknown>): boolean
export function updateGlobalConfig(updates: Record<string, unknown>): boolean
export function updateLayoutConfig(layout: Record<string, unknown>): boolean
export function reorderPlugin(fromIndex: number, toIndex: number): boolean
export function removePluginEntry(index: number): boolean
export function addPluginEntry(entry: Record<string, unknown>): boolean
export function configExists(): boolean
export function createConfigFromDefault(): Record<string, unknown>
export const LOCKFILE_PATH: string
export const PLUGINS_DIR: string
export const PLUGINS_JSON_PATH: string
export const DEFAULT_PLUGINS_JSON_PATH: string
}
declare module "../../plugin-git-handlers.js" {
export function handlePluginInstall(): Promise<void>
export function handlePluginAdd(sources: string[]): Promise<void>
export function handlePluginRemove(names: string[]): Promise<void>
export function handlePluginUpdate(names?: string[]): Promise<void>
export function handlePluginRestore(): Promise<void>
export function handlePluginList(): Promise<void>
export function handlePluginEnable(names: string[]): Promise<void>
export function handlePluginDisable(names: string[]): Promise<void>
export function handlePluginConfig(name: string, options?: { set?: string }): Promise<void>
export function handlePluginCheck(): Promise<void>
}
declare module "./async-plugin-ops.js" {
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
export function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
}
declare module "../async-plugin-ops.js" {
export type ProgressCallback = (
message: string,
type: "info" | "success" | "error" | "warning",
) => void
export interface OperationResult {
success: boolean
installed?: number
failed?: number
updated?: string[]
errors?: string[]
}
export function tuiPluginUpdate(
names?: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginInstall(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginRestore(onProgress?: ProgressCallback): Promise<OperationResult>
export function tuiPluginAdd(
sources: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
export function tuiPluginRemove(
names: string[],
onProgress?: ProgressCallback,
): Promise<OperationResult>
}
declare module "../constants.js" {
export const version: string
export const ORIGIN_NAME: string
export const UPSTREAM_NAME: string
export const QUARTZ_SOURCE_BRANCH: string
export const QUARTZ_SOURCE_REPO: string
export const cwd: string
export const cacheDir: string
export const cacheFile: string
export const fp: string
export const contentCacheFolder: string
}

View File

@ -0,0 +1,24 @@
export interface NotificationMessage {
message: string
type: "success" | "error" | "info"
}
interface NotificationProps {
message: NotificationMessage
}
const COLOR_MAP = {
success: "green",
error: "red",
info: "cyan",
} as const
export function Notification({ message }: NotificationProps) {
return (
<box paddingX={1}>
<text>
<span fg={COLOR_MAP[message.type]}>{message.message}</span>
</text>
</box>
)
}

View File

@ -0,0 +1,116 @@
import { type SelectOption } from "@opentui/core"
import {
createConfigFromDefault,
readDefaultPluginsJson,
writePluginsJson,
} from "../../plugin-data.js"
interface SetupWizardProps {
onComplete: () => void
}
type Choice = "default" | "empty"
export function SetupWizard({ onComplete }: SetupWizardProps) {
const hasDefault = readDefaultPluginsJson() !== null
const choices: { key: Choice; label: string; description: string }[] = [
...(hasDefault
? [
{
key: "default" as Choice,
label: "Use default configuration",
description: "Copy quartz.plugins.default.json as your starting config",
},
]
: []),
{
key: "empty",
label: "Start with empty configuration",
description: "Create a minimal config with no plugins",
},
]
const selectOptions: SelectOption[] = choices.map((choice) => ({
name: choice.label,
description: choice.description,
value: choice.key,
}))
return (
<box flexDirection="column" paddingX={2} paddingY={1}>
<box flexDirection="column" marginBottom={1}>
<text>
<span fg="yellow">
<strong>No configuration found</strong>
</span>
</text>
<text>
<span fg="#888888">
quartz.plugins.json does not exist yet. How would you like to set up your configuration?
</span>
</text>
</box>
<select
options={selectOptions}
focused
onSelect={(_index: number, option: SelectOption | null) => {
if (!option) return
const choice = option.value as Choice
if (choice === "default") {
createConfigFromDefault()
} else {
writePluginsJson({
$schema: "./quartz/plugins/quartz-plugins.schema.json",
configuration: {
pageTitle: "Quartz",
enableSPA: true,
enablePopovers: true,
analytics: { provider: "plausible" },
locale: "en-US",
baseUrl: "quartz.jzhao.xyz",
ignorePatterns: ["private", "templates", ".obsidian"],
defaultDateType: "created",
theme: {
cdnCaching: true,
typography: {
header: "Schibsted Grotesk",
body: "Source Sans Pro",
code: "IBM Plex Mono",
},
colors: {
lightMode: {
light: "#faf8f8",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
darkMode: {
light: "#161618",
lightgray: "#393639",
gray: "#646464",
darkgray: "#d4d4d4",
dark: "#ebebec",
secondary: "#7b97aa",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
textHighlight: "#fff23688",
},
},
},
},
plugins: [],
layout: { groups: {}, byPageType: {} },
})
}
onComplete()
}}
/>
</box>
)
}

View File

@ -0,0 +1,46 @@
const KEYBINDS: Record<string, string[]> = {
Plugins: [
"↑↓ navigate",
"e enable",
"d disable",
"a add",
"r remove",
"i install",
"u update",
"o options",
"s sort",
"Tab switch tab",
"q quit",
],
Layout: [
"↑↓ navigate",
"←→ move zone",
"K/J reorder",
"m move",
"p priority",
"v display",
"c condition",
"x remove",
"g groups",
"t page-types",
"Tab switch tab",
"q quit",
],
Settings: ["↑↓ navigate", "Enter edit", "Tab switch tab", "q quit"],
}
interface StatusBarProps {
activeTab: string
}
export function StatusBar({ activeTab }: StatusBarProps) {
const hints = KEYBINDS[activeTab] ?? []
return (
<box border borderStyle="single" paddingX={1}>
<text>
<span fg="#888888">{hints.join(" │ ")}</span>
</text>
</box>
)
}

View File

@ -0,0 +1,32 @@
import { useState, useCallback } from "react"
import { getLayoutConfig, updateLayoutConfig } from "../../plugin-data.js"
export interface LayoutZone {
position: string
components: Array<{
pluginName: string
displayName: string
priority: number
display: string
condition?: string
group?: string
}>
}
export function useLayout() {
const [layout, setLayout] = useState<Record<string, unknown> | null>(() => getLayoutConfig())
const refresh = useCallback(() => {
setLayout(getLayoutConfig())
}, [])
const save = useCallback(
(newLayout: Record<string, unknown>) => {
updateLayoutConfig(newLayout)
refresh()
},
[refresh],
)
return { layout, refresh, save }
}

View File

@ -0,0 +1,133 @@
import { useState, useCallback } from "react"
import {
getEnrichedPlugins,
updatePluginEntry,
addPluginEntry,
removePluginEntry,
reorderPlugin,
} from "../../plugin-data.js"
export interface EnrichedPlugin {
index: number
name: string
displayName: string
source: string
enabled: boolean
options: Record<string, unknown>
order: number
layout: {
position: string
priority: number
display: string
condition?: string
group?: string
groupOptions?: Record<string, unknown>
} | null
category: string | string[]
installed: boolean
locked: {
source: string
resolved: string
commit: string
installedAt: string
} | null
manifest: Record<string, unknown> | null
currentCommit: string | null
modified: boolean
}
export function usePlugins() {
const [plugins, setPlugins] = useState<EnrichedPlugin[]>(() => getEnrichedPlugins())
const refresh = useCallback(() => {
setPlugins(getEnrichedPlugins())
}, [])
const toggleEnabled = useCallback(
(index: number) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { enabled: !plugin.enabled })
refresh()
},
[plugins, refresh],
)
const setPluginOrder = useCallback(
(index: number, order: number) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { order })
refresh()
},
[plugins, refresh],
)
const setPluginOptions = useCallback(
(index: number, key: string, value: unknown) => {
const plugin = plugins[index]
if (!plugin) return
const newOptions = { ...plugin.options, [key]: value }
updatePluginEntry(plugin.index, { options: newOptions })
refresh()
},
[plugins, refresh],
)
const removePlugin = useCallback(
(index: number) => {
const plugin = plugins[index]
if (!plugin) return false
const result = removePluginEntry(plugin.index)
if (result) refresh()
return result
},
[plugins, refresh],
)
const addPlugin = useCallback(
(source: string) => {
const entry = {
source,
enabled: true,
options: {},
order: 50,
}
const result = addPluginEntry(entry)
if (result) refresh()
return result
},
[refresh],
)
const movePlugin = useCallback(
(fromIndex: number, toIndex: number) => {
const result = reorderPlugin(fromIndex, toIndex)
if (result) refresh()
return result
},
[refresh],
)
const updateLayout = useCallback(
(index: number, layout: EnrichedPlugin["layout"]) => {
const plugin = plugins[index]
if (!plugin) return
updatePluginEntry(plugin.index, { layout })
refresh()
},
[plugins, refresh],
)
return {
plugins,
refresh,
toggleEnabled,
setPluginOrder,
setPluginOptions,
removePlugin,
addPlugin,
movePlugin,
updateLayout,
}
}

View File

@ -0,0 +1,20 @@
import { useState, useCallback } from "react"
import { getGlobalConfig, updateGlobalConfig } from "../../plugin-data.js"
export function useSettings() {
const [config, setConfig] = useState<Record<string, unknown> | null>(() => getGlobalConfig())
const refresh = useCallback(() => {
setConfig(getGlobalConfig())
}, [])
const updateField = useCallback(
(key: string, value: unknown) => {
updateGlobalConfig({ [key]: value })
refresh()
},
[refresh],
)
return { config, refresh, updateField }
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,710 @@
import { useState, useMemo, useCallback, useEffect } from "react"
import { useKeyboard } from "@opentui/react"
import { useSettings } from "../hooks/useSettings.js"
type View = "list" | "edit-string" | "edit-boolean" | "edit-enum" | "edit-array" | "edit-color"
interface SettingsPanelProps {
notify: (message: string, type?: "success" | "error" | "info") => void
onFocusChange: (focused: boolean) => void
}
interface FlatEntry {
keyPath: string[]
displayKey: string
value: unknown
depth: number
isObject: boolean
}
interface FieldSchema {
type: "boolean" | "string" | "enum" | "array" | "number" | "object" | "color"
enumValues?: string[]
description?: string
}
function getFieldSchema(keyPath: string[]): FieldSchema {
const path = keyPath.join(".")
if (["enableSPA", "enablePopovers", "theme.cdnCaching"].includes(path)) {
return { type: "boolean" }
}
if (path === "theme.fontOrigin") {
return { type: "enum", enumValues: ["googleFonts", "local"] }
}
if (path === "defaultDateType") {
return { type: "enum", enumValues: ["created", "modified", "published"] }
}
if (path === "ignorePatterns") {
return { type: "array" }
}
if (path.match(/^theme\.colors\.(lightMode|darkMode)\./)) {
return { type: "color" }
}
return { type: "string" }
}
const HEX_COLOR_REGEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
const CSS_COLOR_FUNCTION_REGEX = /^(rgba?|hsla?|hwb|lab|lch|color)\(.+\)$/i
function isValidColorValue(value: string): boolean {
const trimmed = value.trim()
if (!trimmed) return false
if (HEX_COLOR_REGEX.test(trimmed)) return true
return CSS_COLOR_FUNCTION_REGEX.test(trimmed)
}
function flattenConfig(
obj: Record<string, unknown>,
prefix: string[] = [],
depth = 0,
): FlatEntry[] {
const entries: FlatEntry[] = []
for (const [key, value] of Object.entries(obj)) {
const keyPath = [...prefix, key]
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
entries.push({
keyPath,
displayKey: key,
value,
depth,
isObject: true,
})
entries.push(...flattenConfig(value as Record<string, unknown>, keyPath, depth + 1))
} else {
entries.push({
keyPath,
displayKey: key,
value,
depth,
isObject: false,
})
}
}
return entries
}
function parseJsonOrString(value: string): unknown {
try {
return JSON.parse(value)
} catch {
return value
}
}
function setNestedValue(obj: Record<string, unknown>, keyPath: string[], value: unknown): void {
let current = obj
for (let i = 0; i < keyPath.length - 1; i++) {
if (!(keyPath[i] in current) || typeof current[keyPath[i]] !== "object") {
current[keyPath[i]] = {}
}
current = current[keyPath[i]] as Record<string, unknown>
}
current[keyPath[keyPath.length - 1]] = value
}
function formatStringValue(value: unknown): string {
if (typeof value === "string") return JSON.stringify(value)
if (value === null) return "null"
if (value === undefined) return "undefined"
return String(value)
}
export function SettingsPanel({ notify, onFocusChange }: SettingsPanelProps) {
const { config, updateField } = useSettings()
const [view, setView] = useState<View>("list")
const [editingEntry, setEditingEntry] = useState<FlatEntry | null>(null)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const [highlightedBoolIndex, setHighlightedBoolIndex] = useState(0)
const [highlightedEnumIndex, setHighlightedEnumIndex] = useState(0)
const [highlightedArrayIndex, setHighlightedArrayIndex] = useState(0)
const [arrayItems, setArrayItems] = useState<string[]>([])
const [addingArrayItem, setAddingArrayItem] = useState(false)
const [editingArrayItemIndex, setEditingArrayItemIndex] = useState<number | null>(null)
const [colorError, setColorError] = useState<string | null>(null)
const [collapsed, setCollapsed] = useState<Set<string>>(new Set())
const allEntries = useMemo(() => {
if (!config) return []
return flattenConfig(config)
}, [config])
const visibleEntries = useMemo(() => {
return allEntries.filter((entry) => {
for (let i = entry.keyPath.length - 1; i > 0; i--) {
const parentPath = entry.keyPath.slice(0, i).join(".")
if (collapsed.has(parentPath)) return false
}
return true
})
}, [allEntries, collapsed])
useEffect(() => {
if (highlightedIndex >= visibleEntries.length) {
setHighlightedIndex(Math.max(0, visibleEntries.length - 1))
}
}, [highlightedIndex, visibleEntries.length])
useEffect(() => {
if (!editingEntry) return
const schema = getFieldSchema(editingEntry.keyPath)
if (schema.type === "boolean") {
setHighlightedBoolIndex(Boolean(editingEntry.value) ? 0 : 1)
} else if (schema.type === "enum" && schema.enumValues) {
const idx = schema.enumValues.indexOf(String(editingEntry.value))
setHighlightedEnumIndex(idx >= 0 ? idx : 0)
} else if (schema.type === "array") {
setHighlightedArrayIndex(0)
}
}, [editingEntry])
const exitEdit = useCallback(() => {
setView("list")
setEditingEntry(null)
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
setColorError(null)
onFocusChange(false)
}, [onFocusChange])
const applyValue = useCallback(
(keyPath: string[], value: unknown, exitAfterSave: boolean = true) => {
if (!config) return
if (keyPath.length === 1) {
updateField(keyPath[0], value)
} else {
const fullConfig = { ...config } as Record<string, unknown>
setNestedValue(fullConfig, keyPath, value)
updateField(keyPath[0], fullConfig[keyPath[0]])
}
notify(`Set ${keyPath.join(".")}`, "success")
if (exitAfterSave) {
exitEdit()
}
},
[config, updateField, notify, exitEdit],
)
const enterEdit = useCallback(
(entry: FlatEntry) => {
const schema = getFieldSchema(entry.keyPath)
setEditingEntry(entry)
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
if (schema.type === "boolean") {
setHighlightedBoolIndex(Boolean(entry.value) ? 0 : 1)
setView("edit-boolean")
} else if (schema.type === "enum") {
if (schema.enumValues) {
const idx = schema.enumValues.indexOf(String(entry.value))
setHighlightedEnumIndex(idx >= 0 ? idx : 0)
}
setView("edit-enum")
} else if (schema.type === "array") {
setArrayItems(Array.isArray(entry.value) ? entry.value.map(String) : [])
setHighlightedArrayIndex(0)
setView("edit-array")
} else if (schema.type === "color") {
setColorError(null)
setView("edit-color")
} else {
setView("edit-string")
}
onFocusChange(true)
},
[onFocusChange],
)
useKeyboard((event) => {
if (view !== "list") return
const count = visibleEntries.length
if (count === 0) return
if (event.name === "up") {
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : count - 1))
}
if (event.name === "down") {
setHighlightedIndex((prev) => (prev < count - 1 ? prev + 1 : 0))
}
if (event.name === "return") {
const entry = visibleEntries[highlightedIndex]
if (!entry) return
if (entry.isObject) {
const path = entry.keyPath.join(".")
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
} else {
enterEdit(entry)
}
}
})
useKeyboard((event) => {
if (view !== "edit-boolean" || !editingEntry) return
if (event.name === "escape") {
exitEdit()
return
}
if (event.name === "up" || event.name === "down") {
setHighlightedBoolIndex((prev) => (prev === 0 ? 1 : 0))
}
if (event.name === "return") {
const newVal = highlightedBoolIndex === 0
applyValue(editingEntry.keyPath, newVal)
}
})
useKeyboard((event) => {
if (view !== "edit-enum" || !editingEntry) return
const schema = getFieldSchema(editingEntry.keyPath)
const enumValues = schema.type === "enum" ? (schema.enumValues ?? []) : []
if (event.name === "escape") {
exitEdit()
return
}
if (event.name === "up" || event.name === "down") {
const len = enumValues.length
if (len === 0) return
setHighlightedEnumIndex((prev) =>
event.name === "up" ? (prev > 0 ? prev - 1 : len - 1) : prev < len - 1 ? prev + 1 : 0,
)
}
if (event.name === "return") {
const selected = enumValues[highlightedEnumIndex]
if (selected !== undefined) {
applyValue(editingEntry.keyPath, selected)
}
}
})
useKeyboard((event) => {
if (view !== "edit-array" || !editingEntry) return
if (addingArrayItem || editingArrayItemIndex !== null) {
if (event.name === "escape") {
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
}
return
}
if (event.name === "escape") {
exitEdit()
return
}
const count = arrayItems.length
if (event.name === "up" && count > 0 && !event.shift) {
setHighlightedArrayIndex((prev) => (prev > 0 ? prev - 1 : count - 1))
}
if (event.name === "down" && count > 0 && !event.shift) {
setHighlightedArrayIndex((prev) => (prev < count - 1 ? prev + 1 : 0))
}
if (event.name === "n") {
setAddingArrayItem(true)
}
if (event.name === "return" && count > 0) {
setEditingArrayItemIndex(highlightedArrayIndex)
}
if (event.name === "x" && count > 0) {
const nextItems = arrayItems.filter((_, i) => i !== highlightedArrayIndex)
setArrayItems(nextItems)
setHighlightedArrayIndex(Math.max(0, Math.min(highlightedArrayIndex, nextItems.length - 1)))
applyValue(editingEntry.keyPath, nextItems, false)
}
if (event.name === "up" && event.shift && highlightedArrayIndex > 0) {
const nextItems = [...arrayItems]
const idx = highlightedArrayIndex
;[nextItems[idx - 1], nextItems[idx]] = [nextItems[idx], nextItems[idx - 1]]
setArrayItems(nextItems)
setHighlightedArrayIndex(idx - 1)
applyValue(editingEntry.keyPath, nextItems, false)
}
if (event.name === "down" && event.shift && highlightedArrayIndex < count - 1) {
const nextItems = [...arrayItems]
const idx = highlightedArrayIndex
;[nextItems[idx], nextItems[idx + 1]] = [nextItems[idx + 1], nextItems[idx]]
setArrayItems(nextItems)
setHighlightedArrayIndex(idx + 1)
applyValue(editingEntry.keyPath, nextItems, false)
}
})
useKeyboard((event) => {
if (view !== "edit-string" && view !== "edit-color") return
if (event.name === "escape") exitEdit()
})
if (!config) {
return (
<box padding={1}>
<text>
<span fg="#888888">No configuration found. Run `quartz create` first.</span>
</text>
</box>
)
}
const renderTree = (dimmed: boolean) => {
const baseFg = dimmed ? "#666666" : "#888888"
const highlightFg = dimmed ? "#AAAAAA" : "#FFFFFF"
const renderedEntries = visibleEntries.map((entry, idx) => {
const indent = " ".repeat(entry.depth)
const isHighlighted = idx === highlightedIndex
const marker = isHighlighted ? "▸ " : " "
if (entry.isObject) {
const isCollapsed = collapsed.has(entry.keyPath.join("."))
const arrow = isCollapsed ? "▸" : "▾"
return (
<text key={entry.keyPath.join(".")}>
{isHighlighted ? (
<span fg={highlightFg}>
<strong>
{marker}
{indent}
{entry.displayKey}: {arrow}
</strong>
</span>
) : (
<span fg={baseFg}>
{marker}
{indent}
{entry.displayKey}: {arrow}
</span>
)}
</text>
)
}
const schema = getFieldSchema(entry.keyPath)
let valueText = ""
let valueFg: string | null = null
let swatchColor: string | null = null
if (schema.type === "boolean") {
const enabled = Boolean(entry.value)
valueText = enabled ? "● true" : "○ false"
valueFg = enabled ? "green" : "red"
} else if (schema.type === "enum") {
valueText = String(entry.value ?? "")
} else if (schema.type === "array") {
const length = Array.isArray(entry.value) ? entry.value.length : 0
valueText = `[${length} items]`
} else if (schema.type === "color") {
const colorValue = String(entry.value ?? "")
valueText = colorValue
swatchColor = isValidColorValue(colorValue) ? colorValue : null
} else {
valueText = formatStringValue(entry.value)
}
const displayFg = isHighlighted ? (valueFg ?? highlightFg) : (valueFg ?? baseFg)
return (
<text key={entry.keyPath.join(".")}>
{isHighlighted ? (
<span fg={highlightFg}>
<strong>
{marker}
{indent}
{entry.displayKey}:
</strong>
</span>
) : (
<span fg={baseFg}>
{marker}
{indent}
{entry.displayKey}:
</span>
)}
{swatchColor ? <span fg={swatchColor}> </span> : null}
<span fg={displayFg}>{isHighlighted ? <strong>{valueText}</strong> : valueText}</span>
</text>
)
})
return (
<scrollbox>
<box flexDirection="column">{renderedEntries}</box>
</scrollbox>
)
}
const renderEditPanel = () => {
if (!editingEntry) return null
const schema = getFieldSchema(editingEntry.keyPath)
const pathLabel = editingEntry.keyPath.join(".")
if (view === "edit-boolean") {
const boolItems = [
{ label: "true", isCurrent: Boolean(editingEntry.value) === true },
{ label: "false", isCurrent: Boolean(editingEntry.value) === false },
]
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<box flexDirection="column" marginTop={1}>
{boolItems.map((item, i) => {
const isHighlighted = i === highlightedBoolIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
return (
<text key={item.label}>
<span fg={fg}>
{marker}
{item.label}
{item.isCurrent ? " (current)" : ""}
</span>
</text>
)
})}
</box>
<text>
<span fg="#888888">: toggle Enter: select Esc: back</span>
</text>
</box>
)
}
if (view === "edit-enum" && schema.type === "enum") {
const enumValues = schema.enumValues ?? []
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<box flexDirection="column" marginTop={1}>
{enumValues.map((value, i) => {
const isHighlighted = i === highlightedEnumIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
const currentTag = value === String(editingEntry.value) ? " (current)" : ""
return (
<text key={value}>
<span fg={fg}>
{marker}
{value}
{currentTag}
</span>
</text>
)
})}
</box>
<text>
<span fg="#888888">: navigate Enter: select Esc: back</span>
</text>
</box>
)
}
if (view === "edit-array") {
const hasItems = arrayItems.length > 0
const controlsLabel = "n: add │ x: delete │ Shift+↑/↓: reorder │ Esc: back"
const editingLabel = editingArrayItemIndex !== null ? "Edit item:" : "New item:"
const placeholder =
editingArrayItemIndex !== null ? (arrayItems[editingArrayItemIndex] ?? "") : "pattern"
const showInput = addingArrayItem || editingArrayItemIndex !== null
const arrayLines = hasItems
? arrayItems.map((value, index) => {
const isHighlighted = index === highlightedArrayIndex
const fg = isHighlighted ? "#FFFFFF" : "#888888"
const marker = isHighlighted ? "▸ " : " "
return (
<text key={`${index}-${value}`}>
<span fg={fg}>
{marker}
{JSON.stringify(value)}
</span>
</text>
)
})
: [
<text key="__empty__">
<span fg="#888888">(no items)</span>
</text>,
]
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<scrollbox>
<box flexDirection="column">{arrayLines}</box>
</scrollbox>
<box marginTop={1}>
<text>{editingLabel} </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={placeholder}
focused={showInput}
onSubmit={(value: string) => {
const trimmed = value.trim()
if (!trimmed) {
setAddingArrayItem(false)
setEditingArrayItemIndex(null)
return
}
if (editingArrayItemIndex !== null) {
const nextItems = [...arrayItems]
nextItems[editingArrayItemIndex] = trimmed
setArrayItems(nextItems)
applyValue(editingEntry.keyPath, nextItems, false)
setEditingArrayItemIndex(null)
return
}
const nextItems = [...arrayItems, trimmed]
setArrayItems(nextItems)
setHighlightedArrayIndex(Math.max(0, nextItems.length - 1))
applyValue(editingEntry.keyPath, nextItems, false)
setAddingArrayItem(false)
}}
/>
</box>
</box>
<text>
<span fg="#888888">{controlsLabel}</span>
</text>
</box>
)
}
if (view === "edit-color") {
const currentValue = String(editingEntry.value ?? "")
const showSwatch = isValidColorValue(currentValue)
const errorText = colorError ?? ""
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<text>
<span fg="#888888">Current: {currentValue}</span>
{showSwatch ? <span fg={currentValue}> </span> : null}
</text>
<box marginTop={1}>
<text>Value: </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={currentValue}
focused
onSubmit={(value: string) => {
const trimmed = value.trim()
if (!isValidColorValue(trimmed)) {
setColorError(
"Invalid color. Use #RGB, #RRGGBB, #RRGGBBAA, or a CSS color function like rgba(...)",
)
return
}
setColorError(null)
applyValue(editingEntry.keyPath, trimmed)
}}
/>
</box>
</box>
<text>
<span fg={errorText ? "red" : "#888888"}>{errorText}</span>
</text>
<text>
<span fg="#888888">Enter: save Esc: cancel</span>
</text>
</box>
)
}
if (view === "edit-string") {
const currentLabel = formatStringValue(editingEntry.value)
return (
<box flexDirection="column" paddingX={1}>
<text>
<strong>Edit: {pathLabel}</strong>
</text>
<text>
<span fg="#888888">Current: {currentLabel}</span>
</text>
<box marginTop={1} flexDirection="row">
<text>Value: </text>
<box border borderStyle="single" height={3} flexGrow={1}>
<input
placeholder={currentLabel}
focused
onSubmit={(value: string) => {
const parsed = parseJsonOrString(value)
applyValue(editingEntry.keyPath, parsed)
}}
/>
</box>
</box>
<text>
<span fg="#888888">Enter: save Esc: cancel</span>
</text>
</box>
)
}
return null
}
if (view !== "list" && editingEntry) {
return (
<box flexDirection="row" paddingX={1} gap={1} flexGrow={1}>
<box flexDirection="column" flexGrow={1}>
<text>
<strong>Global Configuration</strong>
</text>
<box
flexDirection="column"
border
borderStyle="single"
paddingX={1}
marginTop={1}
flexGrow={1}
>
{renderTree(true)}
</box>
</box>
<box flexDirection="column" border borderStyle="single" paddingX={1} flexGrow={1}>
{renderEditPanel()}
</box>
</box>
)
}
return (
<box flexDirection="column" paddingX={1} flexGrow={1}>
<text>
<strong>Global Configuration</strong>
</text>
<box
flexDirection="column"
border
borderStyle="single"
paddingX={1}
marginTop={1}
flexGrow={1}
>
{renderTree(false)}
</box>
</box>
)
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"lib": ["ESNext", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"allowJs": true,
"outDir": "dist",
"rootDir": "..",
"declaration": false,
"types": ["node", "react"]
},
"include": ["*.tsx", "**/*.tsx", "**/*.ts", "cli-modules.d.ts"]
}