mirror of
https://github.com/jackyzha0/quartz.git
synced 2026-03-21 21:45:42 -05:00
feat: Quartz TUI
This commit is contained in:
parent
47de5cc55e
commit
292c1a1f2c
1
.gitignore
vendored
1
.gitignore
vendored
@ -10,3 +10,4 @@ private/
|
||||
.replit
|
||||
replit.nix
|
||||
.quartz/
|
||||
quartz/cli/tui/dist/
|
||||
|
||||
1109
package-lock.json
generated
1109
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -17,6 +17,7 @@
|
||||
"check": "tsc --noEmit && npx prettier . --check",
|
||||
"format": "npx prettier . --write",
|
||||
"test": "tsx --test",
|
||||
"build:tui": "node quartz/cli/build-tui.mjs",
|
||||
"profile": "0x -D prof ./quartz/bootstrap-cli.mjs build --concurrency=1",
|
||||
"install-plugins": "npx tsx ./quartz/plugins/loader/install-plugins.ts",
|
||||
"prebuild": "npm run install-plugins"
|
||||
@ -41,6 +42,8 @@
|
||||
"@floating-ui/dom": "^1.7.4",
|
||||
"@myriaddreamin/rehype-typst": "^0.6.0",
|
||||
"@napi-rs/simple-git": "0.1.22",
|
||||
"@opentui/core": "^0.1.80",
|
||||
"@opentui/react": "^0.1.80",
|
||||
"ansi-truncate": "^1.4.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"chokidar": "^5.0.0",
|
||||
@ -65,6 +68,7 @@
|
||||
"preact-render-to-string": "^6.6.5",
|
||||
"pretty-bytes": "^7.1.0",
|
||||
"pretty-time": "^1.1.0",
|
||||
"react": "^19.2.4",
|
||||
"reading-time": "^1.5.0",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-citation": "^2.3.1",
|
||||
@ -101,6 +105,7 @@
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/pretty-time": "^1.1.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@types/yargs": "^17.0.35",
|
||||
|
||||
378
quartz.lock.json
378
quartz.lock.json
@ -1,59 +1,11 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"plugins": {
|
||||
"explorer": {
|
||||
"source": "github:quartz-community/explorer",
|
||||
"resolved": "https://github.com/quartz-community/explorer.git",
|
||||
"commit": "09452e44a4c0e9b2c1d94e6e7a5dc98137dac230",
|
||||
"installedAt": "2026-02-17T17:28:27.727Z"
|
||||
},
|
||||
"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"
|
||||
"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"
|
||||
},
|
||||
"article-title": {
|
||||
"source": "github:quartz-community/article-title",
|
||||
@ -61,101 +13,23 @@
|
||||
"commit": "031872e39ba30d8567af63c6a3891b3054432194",
|
||||
"installedAt": "2026-02-17T17:28:32.818Z"
|
||||
},
|
||||
"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"
|
||||
"backlinks": {
|
||||
"source": "github:quartz-community/backlinks",
|
||||
"resolved": "https://github.com/quartz-community/backlinks.git",
|
||||
"commit": "8adb778566a3d8425d321d6bbc0b673cba9ec63d",
|
||||
"installedAt": "2026-02-17T17:28:30.109Z"
|
||||
},
|
||||
"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"
|
||||
"breadcrumbs": {
|
||||
"source": "github:quartz-community/breadcrumbs",
|
||||
"resolved": "https://github.com/quartz-community/breadcrumbs.git",
|
||||
"commit": "441c3c474b2a7aeecb483aa4421c7a633458e97d",
|
||||
"installedAt": "2026-02-17T17:28:31.401Z"
|
||||
},
|
||||
"darkmode": {
|
||||
"source": "github:quartz-community/darkmode",
|
||||
"resolved": "https://github.com/quartz-community/darkmode.git",
|
||||
"commit": "453e59cf913b2276a5bb6d6bfdc0685304618877",
|
||||
"installedAt": "2026-02-17T17:28:34.395Z"
|
||||
},
|
||||
"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"
|
||||
"canvas-page": {
|
||||
"source": "github:quartz-community/canvas-page",
|
||||
"resolved": "https://github.com/quartz-community/canvas-page.git",
|
||||
"commit": "18107bc8d759a1860caeda10de336ac66bf7f923",
|
||||
"installedAt": "2026-02-18T15:02:34.059Z"
|
||||
},
|
||||
"citations": {
|
||||
"source": "github:quartz-community/citations",
|
||||
@ -163,47 +37,17 @@
|
||||
"commit": "b5db4ea6fd6f3fc9c1ce37cd9cdba0ca9e74ecc8",
|
||||
"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": {
|
||||
"source": "github:quartz-community/cname",
|
||||
"resolved": "https://github.com/quartz-community/cname.git",
|
||||
"commit": "752470f04410576a767b48094e926d41eef3eb18",
|
||||
"installedAt": "2026-02-17T17:28:43.713Z"
|
||||
},
|
||||
"favicon": {
|
||||
"source": "github:quartz-community/favicon",
|
||||
"resolved": "https://github.com/quartz-community/favicon.git",
|
||||
"commit": "71cfff28b7a41f28618f574e672b2b399c6b781d",
|
||||
"installedAt": "2026-02-17T17:28:44.365Z"
|
||||
"comments": {
|
||||
"source": "github:quartz-community/comments",
|
||||
"resolved": "https://github.com/quartz-community/comments.git",
|
||||
"commit": "bf03ced30c7828c3ae4dae5d313e596983f5d453",
|
||||
"installedAt": "2026-02-18T19:52:41.699Z"
|
||||
},
|
||||
"content-index": {
|
||||
"source": "github:quartz-community/content-index",
|
||||
@ -211,17 +55,179 @@
|
||||
"commit": "2b9c62d659b7f8e9e7cc028a201cae7061224653",
|
||||
"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": {
|
||||
"source": "github:quartz-community/og-image",
|
||||
"resolved": "https://github.com/quartz-community/og-image.git",
|
||||
"commit": "9a00809fccb0e1e4b7db4ee6e6eed002952396f4",
|
||||
"installedAt": "2026-02-17T17:28:45.533Z"
|
||||
},
|
||||
"canvas-page": {
|
||||
"source": "github:quartz-community/canvas-page",
|
||||
"resolved": "https://github.com/quartz-community/canvas-page.git",
|
||||
"commit": "683c2da6bb068d3e596cdb3bfcca68007829cd17",
|
||||
"installedAt": "2026-02-17T17:28:46.012Z"
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,6 +32,47 @@ import {
|
||||
} from "./cli/args.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))
|
||||
.scriptName("quartz")
|
||||
.version(version)
|
||||
@ -59,8 +100,11 @@ yargs(hideBin(process.argv))
|
||||
.command("migrate", "Migrate old config to quartz.plugins.json", CommonArgv, async () => {
|
||||
await handleMigrate()
|
||||
})
|
||||
.command("tui", "Launch interactive plugin manager", CommonArgv, async () => {
|
||||
await launchTui()
|
||||
})
|
||||
.command(
|
||||
"plugin <subcommand>",
|
||||
"plugin [subcommand]",
|
||||
"Manage Quartz plugins",
|
||||
(yargs) => {
|
||||
return yargs
|
||||
@ -125,10 +169,11 @@ yargs(hideBin(process.argv))
|
||||
.command("check", "Check for plugin updates", CommonArgv, async () => {
|
||||
await handlePluginCheck()
|
||||
})
|
||||
.demandCommand(1, "Please specify a plugin subcommand")
|
||||
.demandCommand(0, "")
|
||||
},
|
||||
async () => {
|
||||
// This handler is called when no subcommand is provided
|
||||
async (argv) => {
|
||||
if (!argv._.includes("plugin") || argv._.length > 1) return
|
||||
await launchTui()
|
||||
},
|
||||
)
|
||||
.showHelpOnFail(false)
|
||||
|
||||
21
quartz/cli/build-tui.mjs
Normal file
21
quartz/cli/build-tui.mjs
Normal 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
280
quartz/cli/plugin-data.js
Normal 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 }
|
||||
@ -2,51 +2,21 @@ import fs from "fs"
|
||||
import path from "path"
|
||||
import { execSync } from "child_process"
|
||||
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"])
|
||||
|
||||
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) {
|
||||
try {
|
||||
console.log(styleText("cyan", ` → ${name}: installing dependencies...`))
|
||||
@ -128,48 +98,6 @@ async function regeneratePluginIndex() {
|
||||
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() {
|
||||
const lockfile = readLockfile()
|
||||
|
||||
|
||||
114
quartz/cli/tui/App.tsx
Normal file
114
quartz/cli/tui/App.tsx
Normal 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 />)
|
||||
572
quartz/cli/tui/async-plugin-ops.ts
Normal file
572
quartz/cli/tui/async-plugin-ops.ts
Normal 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
175
quartz/cli/tui/cli-modules.d.ts
vendored
Normal 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
|
||||
}
|
||||
24
quartz/cli/tui/components/Notification.tsx
Normal file
24
quartz/cli/tui/components/Notification.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
quartz/cli/tui/components/SetupWizard.tsx
Normal file
116
quartz/cli/tui/components/SetupWizard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
46
quartz/cli/tui/components/StatusBar.tsx
Normal file
46
quartz/cli/tui/components/StatusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
quartz/cli/tui/hooks/useLayout.ts
Normal file
32
quartz/cli/tui/hooks/useLayout.ts
Normal 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 }
|
||||
}
|
||||
133
quartz/cli/tui/hooks/usePlugins.ts
Normal file
133
quartz/cli/tui/hooks/usePlugins.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
20
quartz/cli/tui/hooks/useSettings.ts
Normal file
20
quartz/cli/tui/hooks/useSettings.ts
Normal 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 }
|
||||
}
|
||||
1514
quartz/cli/tui/panels/LayoutPanel.tsx
Normal file
1514
quartz/cli/tui/panels/LayoutPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1578
quartz/cli/tui/panels/PluginsPanel.tsx
Normal file
1578
quartz/cli/tui/panels/PluginsPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
710
quartz/cli/tui/panels/SettingsPanel.tsx
Normal file
710
quartz/cli/tui/panels/SettingsPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
quartz/cli/tui/tsconfig.json
Normal file
19
quartz/cli/tui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user