From 232e4c142b99da127d388f4ea277de1f7cc835a7 Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 20 Aug 2026 15:09:15 +0800 Subject: [PATCH 01/17] feat: init setup for cloudfare workder --- github-auth-worker/.editorconfig | 12 + github-auth-worker/.gitignore | 167 ++++++ github-auth-worker/.prettierrc | 6 + github-auth-worker/package.json | 16 + github-auth-worker/src/index.ts | 18 + github-auth-worker/tsconfig.json | 4 + github-auth-worker/wrangler.jsonc | 8 + pnpm-lock.yaml | 834 +++++++++++++++++++++++++++++- pnpm-workspace.yaml | 1 + 9 files changed, 1061 insertions(+), 5 deletions(-) create mode 100644 github-auth-worker/.editorconfig create mode 100644 github-auth-worker/.gitignore create mode 100644 github-auth-worker/.prettierrc create mode 100644 github-auth-worker/package.json create mode 100644 github-auth-worker/src/index.ts create mode 100644 github-auth-worker/tsconfig.json create mode 100644 github-auth-worker/wrangler.jsonc diff --git a/github-auth-worker/.editorconfig b/github-auth-worker/.editorconfig new file mode 100644 index 0000000..a727df3 --- /dev/null +++ b/github-auth-worker/.editorconfig @@ -0,0 +1,12 @@ +# http://editorconfig.org +root = true + +[*] +indent_style = tab +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.yml] +indent_style = space diff --git a/github-auth-worker/.gitignore b/github-auth-worker/.gitignore new file mode 100644 index 0000000..4138168 --- /dev/null +++ b/github-auth-worker/.gitignore @@ -0,0 +1,167 @@ +# Logs + +logs +_.log +npm-debug.log_ +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) + +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# Runtime data + +pids +_.pid +_.seed +\*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover + +lib-cov + +# Coverage directory used by tools like istanbul + +coverage +\*.lcov + +# nyc test coverage + +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) + +.grunt + +# Bower dependency directory (https://bower.io/) + +bower_components + +# node-waf configuration + +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) + +build/Release + +# Dependency directories + +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) + +web_modules/ + +# TypeScript cache + +\*.tsbuildinfo + +# Optional npm cache directory + +.npm + +# Optional eslint cache + +.eslintcache + +# Optional stylelint cache + +.stylelintcache + +# Microbundle cache + +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history + +.node_repl_history + +# Output of 'npm pack' + +\*.tgz + +# Yarn Integrity file + +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) + +.cache +.parcel-cache + +# Next.js build output + +.next +out + +# Nuxt.js build / generate output + +.nuxt +dist + +# Gatsby files + +.cache/ + +# Comment in the public line in if your project uses Gatsby and not Next.js + +# https://nextjs.org/blog/next-9-1#public-directory-support + +# public + +# vuepress build output + +.vuepress/dist + +# vuepress v2.x temp and cache directory + +.temp +.cache + +# Docusaurus cache and generated files + +.docusaurus + +# Serverless directories + +.serverless/ + +# FuseBox cache + +.fusebox/ + +# DynamoDB Local files + +.dynamodb/ + +# TernJS port file + +.tern-port + +# Stores VSCode versions used for testing VSCode extensions + +.vscode-test + +# yarn v2 + +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.\* + +# wrangler project + +.dev.vars* +!.dev.vars.example +.env* +!.env.example +.wrangler/ diff --git a/github-auth-worker/.prettierrc b/github-auth-worker/.prettierrc new file mode 100644 index 0000000..5c7b5d3 --- /dev/null +++ b/github-auth-worker/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 140, + "singleQuote": true, + "semi": true, + "useTabs": true +} diff --git a/github-auth-worker/package.json b/github-auth-worker/package.json new file mode 100644 index 0000000..c8ecb02 --- /dev/null +++ b/github-auth-worker/package.json @@ -0,0 +1,16 @@ +{ + "name": "github-auth-worker", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "wrangler deploy", + "dev": "wrangler dev", + "start": "wrangler dev", + "cf-typegen": "wrangler types" + }, + "devDependencies": { + "typescript": "^5.5.2", + "wrangler": "^3.101.0", + "@types/node": "^24.10.1" + } +} \ No newline at end of file diff --git a/github-auth-worker/src/index.ts b/github-auth-worker/src/index.ts new file mode 100644 index 0000000..c2c916a --- /dev/null +++ b/github-auth-worker/src/index.ts @@ -0,0 +1,18 @@ +/** + * Welcome to Cloudflare Workers! This is your first worker. + * + * - Run `npm run dev` in your terminal to start a development server + * - Open a browser tab at http://localhost:8787/ to see your worker in action + * - Run `npm run deploy` to publish your worker + * + * Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the + * `Env` object can be regenerated with `npm run cf-typegen`. + * + * Learn more at https://developers.cloudflare.com/workers/ + */ + +export default { + async fetch(request, env, ctx): Promise { + return new Response("Hello World!"); + }, +} satisfies ExportedHandler; diff --git a/github-auth-worker/tsconfig.json b/github-auth-worker/tsconfig.json new file mode 100644 index 0000000..e0e5f5a --- /dev/null +++ b/github-auth-worker/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.json", + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/github-auth-worker/wrangler.jsonc b/github-auth-worker/wrangler.jsonc new file mode 100644 index 0000000..b89dd64 --- /dev/null +++ b/github-auth-worker/wrangler.jsonc @@ -0,0 +1,8 @@ +{ + "name": "markdown-editor-github-auth", + "main": "src/index.ts", + "compatibility_date": "2025-07-18", + "observability": { + "enabled": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abb0628..6c2c4ed 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,18 @@ importers: crates: {} + github-auth-worker: + devDependencies: + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + typescript: + specifier: ^5.5.2 + version: 5.8.3 + wrangler: + specifier: ^3.101.0 + version: 3.114.17 + packages: /@antfu/install-pkg@1.1.0: @@ -366,6 +378,71 @@ packages: resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} dev: false + /@cloudflare/kv-asset-handler@0.3.4: + resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} + engines: {node: '>=16.13'} + dependencies: + mime: 3.0.0 + dev: true + + /@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0): + resolution: {integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==} + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + dependencies: + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + dev: true + + /@cloudflare/workerd-darwin-64@1.20250718.0: + resolution: {integrity: sha512-FHf4t7zbVN8yyXgQ/r/GqLPaYZSGUVzeR7RnL28Mwj2djyw2ZergvytVc7fdGcczl6PQh+VKGfZCfUqpJlbi9g==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@cloudflare/workerd-darwin-arm64@1.20250718.0: + resolution: {integrity: sha512-fUiyUJYyqqp4NqJ0YgGtp4WJh/II/YZsUnEb6vVy5Oeas8lUOxnN+ZOJ8N/6/5LQCVAtYCChRiIrBbfhTn5Z8Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@cloudflare/workerd-linux-64@1.20250718.0: + resolution: {integrity: sha512-5+eb3rtJMiEwp08Kryqzzu8d1rUcK+gdE442auo5eniMpT170Dz0QxBrqkg2Z48SFUPYbj+6uknuA5tzdRSUSg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@cloudflare/workerd-linux-arm64@1.20250718.0: + resolution: {integrity: sha512-Aa2M/DVBEBQDdATMbn217zCSFKE+ud/teS+fFS+OQqKABLn0azO2qq6ANAHYOIE6Q3Sq4CxDIQr8lGdaJHwUog==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@cloudflare/workerd-windows-64@1.20250718.0: + resolution: {integrity: sha512-dY16RXKffmugnc67LTbyjdDHZn5NoTF1yHEf2fN4+OaOnoGSp3N1x77QubTDwqZ9zECWxgQfDLjddcH8dWeFhg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@codemirror/autocomplete@0.19.15: resolution: {integrity: sha512-GQWzvvuXxNUyaEk+5gawbAD8s51/v2Chb++nx0e2eGWrphWk42isBtzOMdc3DxrxrZtPZ55q2ldNp+6G8KJLIQ==} dependencies: @@ -1032,6 +1109,21 @@ packages: w3c-keyname: 2.2.8 dev: false + /@cspotcode/source-map-support@0.8.1: + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + dev: true + + /@emnapi/runtime@1.11.3: + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: true + optional: true + /@emotion/babel-plugin@11.13.5: resolution: {integrity: sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ==} dependencies: @@ -1166,6 +1258,24 @@ packages: resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} dev: false + /@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19): + resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} + peerDependencies: + esbuild: '*' + dependencies: + esbuild: 0.17.19 + dev: true + + /@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19): + resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} + peerDependencies: + esbuild: '*' + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + dev: true + /@esbuild/aix-ppc64@0.27.2: resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1175,6 +1285,15 @@ packages: dev: true optional: true + /@esbuild/android-arm64@0.17.19: + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm64@0.27.2: resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} @@ -1184,6 +1303,15 @@ packages: dev: true optional: true + /@esbuild/android-arm@0.17.19: + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-arm@0.27.2: resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} @@ -1193,6 +1321,15 @@ packages: dev: true optional: true + /@esbuild/android-x64@0.17.19: + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + /@esbuild/android-x64@0.27.2: resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} @@ -1202,6 +1339,15 @@ packages: dev: true optional: true + /@esbuild/darwin-arm64@0.17.19: + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-arm64@0.27.2: resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} @@ -1211,6 +1357,15 @@ packages: dev: true optional: true + /@esbuild/darwin-x64@0.17.19: + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + /@esbuild/darwin-x64@0.27.2: resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} @@ -1220,6 +1375,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-arm64@0.17.19: + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-arm64@0.27.2: resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} @@ -1229,6 +1393,15 @@ packages: dev: true optional: true + /@esbuild/freebsd-x64@0.17.19: + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/freebsd-x64@0.27.2: resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} @@ -1238,6 +1411,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm64@0.17.19: + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm64@0.27.2: resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} @@ -1247,6 +1429,15 @@ packages: dev: true optional: true + /@esbuild/linux-arm@0.17.19: + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-arm@0.27.2: resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} @@ -1256,6 +1447,15 @@ packages: dev: true optional: true + /@esbuild/linux-ia32@0.17.19: + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ia32@0.27.2: resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} @@ -1265,6 +1465,15 @@ packages: dev: true optional: true + /@esbuild/linux-loong64@0.17.19: + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-loong64@0.27.2: resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} @@ -1274,6 +1483,15 @@ packages: dev: true optional: true + /@esbuild/linux-mips64el@0.17.19: + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-mips64el@0.27.2: resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} @@ -1283,6 +1501,15 @@ packages: dev: true optional: true + /@esbuild/linux-ppc64@0.17.19: + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-ppc64@0.27.2: resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} @@ -1292,6 +1519,15 @@ packages: dev: true optional: true + /@esbuild/linux-riscv64@0.17.19: + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-riscv64@0.27.2: resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} @@ -1301,6 +1537,15 @@ packages: dev: true optional: true + /@esbuild/linux-s390x@0.17.19: + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-s390x@0.27.2: resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} @@ -1310,6 +1555,15 @@ packages: dev: true optional: true + /@esbuild/linux-x64@0.17.19: + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + /@esbuild/linux-x64@0.27.2: resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} @@ -1328,6 +1582,15 @@ packages: dev: true optional: true + /@esbuild/netbsd-x64@0.17.19: + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/netbsd-x64@0.27.2: resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} @@ -1346,6 +1609,15 @@ packages: dev: true optional: true + /@esbuild/openbsd-x64@0.17.19: + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + /@esbuild/openbsd-x64@0.27.2: resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -1364,6 +1636,15 @@ packages: dev: true optional: true + /@esbuild/sunos-x64@0.17.19: + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + /@esbuild/sunos-x64@0.27.2: resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} @@ -1373,6 +1654,15 @@ packages: dev: true optional: true + /@esbuild/win32-arm64@0.17.19: + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-arm64@0.27.2: resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} @@ -1382,6 +1672,15 @@ packages: dev: true optional: true + /@esbuild/win32-ia32@0.17.19: + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-ia32@0.27.2: resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} @@ -1391,6 +1690,15 @@ packages: dev: true optional: true + /@esbuild/win32-x64@0.17.19: + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@esbuild/win32-x64@0.27.2: resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -1417,6 +1725,11 @@ packages: - supports-color dev: true + /@fastify/busboy@2.1.1: + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + dev: true + /@floating-ui/core@1.7.1: resolution: {integrity: sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==} dependencies: @@ -1473,6 +1786,186 @@ packages: - supports-color dev: false + /@img/sharp-darwin-arm64@0.33.5: + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + dev: true + optional: true + + /@img/sharp-darwin-x64@0.33.5: + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + dev: true + optional: true + + /@img/sharp-libvips-darwin-arm64@1.0.4: + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-darwin-x64@1.0.4: + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm64@1.0.4: + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-arm@1.0.5: + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-s390x@1.0.4: + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linux-x64@1.0.4: + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-arm64@1.0.4: + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-libvips-linuxmusl-x64@1.0.4: + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-linux-arm64@0.33.5: + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + dev: true + optional: true + + /@img/sharp-linux-arm@0.33.5: + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + dev: true + optional: true + + /@img/sharp-linux-s390x@0.33.5: + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + dev: true + optional: true + + /@img/sharp-linux-x64@0.33.5: + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-arm64@0.33.5: + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + dev: true + optional: true + + /@img/sharp-linuxmusl-x64@0.33.5: + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + dev: true + optional: true + + /@img/sharp-wasm32@0.33.5: + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/runtime': 1.11.3 + dev: true + optional: true + + /@img/sharp-win32-ia32@0.33.5: + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@img/sharp-win32-x64@0.33.5: + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@jridgewell/gen-mapping@0.3.8: resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} engines: {node: '>=6.0.0'} @@ -1485,7 +1978,6 @@ packages: /@jridgewell/resolve-uri@3.1.2: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} - dev: false /@jridgewell/set-array@1.2.1: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} @@ -1494,7 +1986,6 @@ packages: /@jridgewell/sourcemap-codec@1.5.5: resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - dev: false /@jridgewell/trace-mapping@0.3.25: resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -1503,6 +1994,13 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: false + /@jridgewell/trace-mapping@0.3.9: + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + dev: true + /@lezer/common@0.15.12: resolution: {integrity: sha512-edfwCxNLnzq5pBA/yaIhwJ3U3Kz8VAUOTRg0hhxaizaI1N+qxV7EXDv/kLCkLeq2RzSFvxexlaj5Mzfn2kY0Ig==} dev: false @@ -3088,6 +3586,17 @@ packages: acorn: 8.14.1 dev: true + /acorn-walk@8.3.2: + resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@8.14.0: + resolution: {integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + /acorn@8.14.1: resolution: {integrity: sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==} engines: {node: '>=0.4.0'} @@ -3161,6 +3670,12 @@ packages: es-shim-unscopables: 1.0.0 dev: true + /as-table@1.0.55: + resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + dependencies: + printable-characters: 1.0.42 + dev: true + /available-typed-arrays@1.0.5: resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} engines: {node: '>= 0.4'} @@ -3187,6 +3702,10 @@ packages: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} dev: true + /blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + dev: true + /brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} dependencies: @@ -3312,6 +3831,25 @@ packages: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: true + /color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + requiresBuild: true + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.4 + dev: true + optional: true + + /color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + requiresBuild: true + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + dev: true + optional: true + /colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} dev: true @@ -3361,6 +3899,11 @@ packages: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} dev: false + /cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + dev: true + /cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} @@ -3719,6 +4262,10 @@ packages: lodash-es: 4.17.21 dev: false + /data-uri-to-buffer@2.0.2: + resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} + dev: true + /date-fns@2.28.0: resolution: {integrity: sha512-8d35hViGYx/QH0icHYCeLmsLmMUheMmTyV9Fcm6gvNwdw31yXXH+O85sOBJ+OLnLQMKZowvpKb6FgMIQjcpvQw==} engines: {node: '>=0.11'} @@ -3786,6 +4333,10 @@ packages: object-keys: 1.1.1 dev: true + /defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + dev: true + /delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} dependencies: @@ -3801,6 +4352,13 @@ packages: engines: {node: '>=6'} dev: false + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + requiresBuild: true + dev: true + optional: true + /devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} dependencies: @@ -3935,6 +4493,36 @@ packages: is-symbol: 1.0.4 dev: true + /esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + dev: true + /esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -4298,6 +4886,10 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker@0.6.1: + resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} + dev: true + /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: false @@ -4307,9 +4899,13 @@ packages: engines: {node: '>=0.10.0'} dev: true + /exit-hook@2.2.1: + resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} + engines: {node: '>=6'} + dev: true + /exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} - dev: false /extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -4434,6 +5030,13 @@ packages: has-symbols: 1.0.3 dev: true + /get-source@2.0.12: + resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + dev: true + /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} @@ -4462,6 +5065,10 @@ packages: is-glob: 4.0.3 dev: true + /glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: true + /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} dependencies: @@ -4682,6 +5289,12 @@ packages: resolution: {integrity: sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=} dev: false + /is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} + requiresBuild: true + dev: true + optional: true + /is-bigint@1.0.4: resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} dependencies: @@ -4975,6 +5588,12 @@ packages: js-tokens: 4.0.0 dev: false + /magic-string@0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + dev: true + /magic-string@0.30.19: resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} dependencies: @@ -5481,6 +6100,33 @@ packages: picomatch: 2.3.1 dev: true + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: true + + /miniflare@3.20250718.3: + resolution: {integrity: sha512-JuPrDJhwLrNLEJiNLWO7ZzJrv/Vv9kZuwMYCfv0LskQDM6Eonw4OvywO3CH/wCGjgHzha/qyjUh8JQ068TjDgQ==} + engines: {node: '>=16.13'} + hasBin: true + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250718.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: @@ -5512,6 +6158,11 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + /mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + dev: true + /nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5564,6 +6215,10 @@ packages: es-abstract: 1.21.1 dev: true + /ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + dev: true + /once@1.4.0: resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} dependencies: @@ -5654,13 +6309,16 @@ packages: /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + /path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + dev: true + /path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} /pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - dev: false /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -5755,6 +6413,10 @@ packages: react-transition-group: 4.4.5(react-dom@19.1.0)(react@19.1.0) dev: false + /printable-characters@1.0.42: + resolution: {integrity: sha1-Pxjpd6m9jrN/zE/1ZZ176Qhos9g=} + dev: true + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -6169,6 +6831,26 @@ packages: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} dev: false + /rollup-plugin-inject@3.0.2: + resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + dev: true + + /rollup-plugin-node-polyfills@0.2.1: + resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} + dependencies: + rollup-plugin-inject: 3.0.2 + dev: true + + /rollup-pluginutils@2.8.2: + resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} + dependencies: + estree-walker: 0.6.1 + dev: true + /rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} dev: false @@ -6423,6 +7105,37 @@ packages: resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} dev: false + /sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + requiresBuild: true + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + dev: true + optional: true + /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -6443,6 +7156,14 @@ packages: object-inspect: 1.12.3 dev: true + /simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} + requiresBuild: true + dependencies: + is-arrayish: 0.3.4 + dev: true + optional: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -6457,6 +7178,15 @@ packages: engines: {node: '>=0.10.0'} dev: false + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + dev: true + + /sourcemap-codec@1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + dev: true + /space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} dev: false @@ -6469,6 +7199,18 @@ packages: resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} dev: true + /stacktracey@2.2.0: + resolution: {integrity: sha512-ETyQEz+CzXiLjEbyJqpbp+/T79RQD/6wqFucRBIlVNZfYq2Ay7wbretD4cxpbymZlaPWx58aIhPEY1Cr8DlVvg==} + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + dev: true + + /stoppable@1.1.0: + resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} + engines: {node: '>=4', npm: '>=6'} + dev: true + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -6662,7 +7404,6 @@ packages: /ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} - dev: false /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} @@ -6677,6 +7418,23 @@ packages: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} dev: true + /undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.1.1 + dev: true + + /unenv@2.0.0-rc.14: + resolution: {integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==} + dependencies: + defu: 6.1.7 + exsolve: 1.0.7 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.1 + dev: true + /unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} dependencies: @@ -6893,6 +7651,47 @@ packages: engines: {node: '>=0.10.0'} dev: true + /workerd@1.20250718.0: + resolution: {integrity: sha512-kqkIJP/eOfDlUyBzU7joBg+tl8aB25gEAGqDap+nFWb+WHhnooxjGHgxPBy3ipw2hnShPFNOQt5lFRxbwALirg==} + engines: {node: '>=16'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20250718.0 + '@cloudflare/workerd-darwin-arm64': 1.20250718.0 + '@cloudflare/workerd-linux-64': 1.20250718.0 + '@cloudflare/workerd-linux-arm64': 1.20250718.0 + '@cloudflare/workerd-windows-64': 1.20250718.0 + dev: true + + /wrangler@3.114.17: + resolution: {integrity: sha512-tAvf7ly+tB+zwwrmjsCyJ2pJnnc7SZhbnNwXbH+OIdVas3zTSmjcZOjmLKcGGptssAA3RyTKhcF9BvKZzMUycA==} + engines: {node: '>=16.17.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20250408.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + dependencies: + '@cloudflare/kv-asset-handler': 0.3.4 + '@cloudflare/unenv-preset': 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250718.0) + '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) + '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250718.3 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250718.0 + optionalDependencies: + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6906,6 +7705,19 @@ packages: resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} dev: true + /ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + /y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6939,6 +7751,18 @@ packages: engines: {node: '>=10'} dev: true + /youch@3.3.4: + resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.2.0 + dev: true + + /zod@3.22.3: + resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} + dev: true + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 01e7bba..d14c059 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: # all packages in subdirs of components/ - 'client/**' - 'crates/**' + - 'github-auth-worker' From 55f9c7fb935a6b30189991e297bd2b707161d8fd Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 20 Aug 2026 15:57:08 +0800 Subject: [PATCH 02/17] chore: update typescript to v7 --- .github/workflows/build.yml | 3 +- .husky/pre-commit | 2 +- client/package.json | 6 +- client/src/assets.d.ts | 2 + .../Editor/configs/crepeFeatures.ts | 7 +- client/tsconfig.json | 2 +- github-auth-worker/package.json | 8 +- github-auth-worker/src/index.ts | 4 +- github-auth-worker/tsconfig.json | 9 +- github-auth-worker/worker-configuration.d.ts | 5 + package.json | 8 +- pnpm-lock.yaml | 307 ++++++++++++++---- tsconfig.eslint.json | 2 +- tsconfig.json | 2 - 14 files changed, 290 insertions(+), 77 deletions(-) create mode 100644 client/src/assets.d.ts create mode 100644 github-auth-worker/worker-configuration.d.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 55ac992..a6fa6a7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,7 @@ jobs: run: cargo install cargo-make - name: Run tests - run: pnpm ts:check && pnpm lint && cd ./crates && cargo make ci + run: pnpm type-check:full && pnpm lint && cd ./crates && cargo make ci build-macos: name: Build macOS @@ -219,4 +219,3 @@ jobs: prerelease: false env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - diff --git a/.husky/pre-commit b/.husky/pre-commit index a2483cb..4669f93 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -pnpm ts:check && pnpm lint && cd ./crates && cargo make commit +pnpm type-check && pnpm lint && cd ./crates && cargo make commit diff --git a/client/package.json b/client/package.json index bba7b93..bb58abf 100644 --- a/client/package.json +++ b/client/package.json @@ -6,7 +6,7 @@ "dev": "cross-env SERVER_PORT=3024 rsbuild dev --port 4000", "build": "cross-env SERVER_PORT=7024 rsbuild build", "preview": "rsbuild preview", - "ts:check": "tsc --noEmit" + "type-check": "tsc --noEmit" }, "dependencies": { "@codemirror/lang-markdown": "0.19.6", @@ -56,6 +56,6 @@ "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", "@types/react-redux": "7.1.34", - "typescript": "^5.8.3" + "typescript": "^7.0.2" } -} +} \ No newline at end of file diff --git a/client/src/assets.d.ts b/client/src/assets.d.ts new file mode 100644 index 0000000..875203d --- /dev/null +++ b/client/src/assets.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.scss'; diff --git a/client/src/components/Editor/configs/crepeFeatures.ts b/client/src/components/Editor/configs/crepeFeatures.ts index 9e7899b..bc53fa1 100644 --- a/client/src/components/Editor/configs/crepeFeatures.ts +++ b/client/src/components/Editor/configs/crepeFeatures.ts @@ -1,6 +1,3 @@ -import { BlockEditFeatureConfig } from '@milkdown/crepe/lib/types/feature/block-edit'; -import { CodeMirrorFeatureConfig } from '@milkdown/crepe/lib/types/feature/code-mirror'; -import { ToolbarFeatureConfig } from '@milkdown/crepe/lib/types/feature/toolbar'; import { commandsCtx } from '@milkdown/kit/core'; import { clearTextInCurrentBlockCommand, @@ -16,6 +13,10 @@ import { containerSchema, ContainerType } from '../plugins/plugin-container'; import { highlightSchema, showColorPickerCommand } from '../plugins/plugin-highlight'; import { iframeBlockSchema } from '../plugins/plugin-iframe'; +import type { BlockEditFeatureConfig } from '@milkdown/crepe/feature/block-edit'; +import type { CodeMirrorFeatureConfig } from '@milkdown/crepe/feature/code-mirror'; +import type { ToolbarFeatureConfig } from '@milkdown/crepe/feature/toolbar'; + import Toast from '@/utils/Toast'; import { nextTick, uid } from '@/utils/utils'; diff --git a/client/tsconfig.json b/client/tsconfig.json index 7f17644..f804cde 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -5,7 +5,7 @@ "noImplicitAny": true, "module": "esnext", "target": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "lib": ["esnext", "dom", "DOM.Iterable"], "skipLibCheck": true, "jsx": "react-jsx", diff --git a/github-auth-worker/package.json b/github-auth-worker/package.json index c8ecb02..ae0644a 100644 --- a/github-auth-worker/package.json +++ b/github-auth-worker/package.json @@ -6,11 +6,13 @@ "deploy": "wrangler deploy", "dev": "wrangler dev", "start": "wrangler dev", - "cf-typegen": "wrangler types" + "cf-typegen": "wrangler types --experimental-include-runtime", + "type-check": "tsc --noEmit", + "type-check:full": "pnpm cf-typegen && pnpm type-check" }, "devDependencies": { - "typescript": "^5.5.2", + "typescript": "^7.0.2", "wrangler": "^3.101.0", "@types/node": "^24.10.1" } -} \ No newline at end of file +} diff --git a/github-auth-worker/src/index.ts b/github-auth-worker/src/index.ts index c2c916a..f0ba832 100644 --- a/github-auth-worker/src/index.ts +++ b/github-auth-worker/src/index.ts @@ -12,7 +12,7 @@ */ export default { - async fetch(request, env, ctx): Promise { - return new Response("Hello World!"); + fetch(): Response { + return new Response('Hello World!'); }, } satisfies ExportedHandler; diff --git a/github-auth-worker/tsconfig.json b/github-auth-worker/tsconfig.json index e0e5f5a..570c134 100644 --- a/github-auth-worker/tsconfig.json +++ b/github-auth-worker/tsconfig.json @@ -1,4 +1,11 @@ { "extends": "../tsconfig.json", - "include": ["worker-configuration.d.ts", "src/**/*.ts"] + "compilerOptions": { + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "types": ["./worker-configuration.d.ts", "./.wrangler/types/runtime.d.ts"] + }, + "include": ["src/**/*.ts"] } diff --git a/github-auth-worker/worker-configuration.d.ts b/github-auth-worker/worker-configuration.d.ts new file mode 100644 index 0000000..bf0ebf8 --- /dev/null +++ b/github-auth-worker/worker-configuration.d.ts @@ -0,0 +1,5 @@ +// Generated by Wrangler by running `wrangler types --experimental-include-runtime` + +// eslint-disable-next-line @typescript-eslint/no-empty-interface,@typescript-eslint/no-empty-object-type +interface Env { +} diff --git a/package.json b/package.json index 6c6e5a8..86e74ed 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,15 @@ "build:macos": "pnpm --filter crates build-macos", "build:windows": "pnpm --filter crates build-windows", "build:all": "pnpm --filter crates build", - "ts:check": "pnpm --filter client ts:check", - "lint": "eslint client/**/*.{ts,tsx} --fix", + "type-check": "pnpm --filter client type-check && pnpm --filter github-auth-worker type-check", + "type-check:full": "pnpm --filter github-auth-worker cf-typegen && pnpm type-check", + "lint": "eslint client/**/*.{ts,tsx} github-auth-worker/src/**/*.ts --fix", "release": "tsx scripts/bump-version.ts", "prepare": "husky" }, "devDependencies": { "@types/semver": "^7.5.8", + "@typescript/native": "npm:typescript@^7.0.2", "@typescript-eslint/eslint-plugin": "^5.9.0", "@typescript-eslint/parser": "^5.9.0", "concurrently": "^7.1.0", @@ -39,6 +41,6 @@ "semver": "^7.6.3", "tslib": "^2.3.1", "tsx": "^4.19.2", - "typescript": "^5.8.3" + "typescript": "npm:@typescript/typescript6@^6.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c2c4ed..6c573ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,10 +13,13 @@ importers: version: 7.7.1 '@typescript-eslint/eslint-plugin': specifier: ^5.9.0 - version: 5.53.0(@typescript-eslint/parser@5.53.0)(eslint@8.34.0)(typescript@5.8.3) + version: 5.53.0(@typescript-eslint/parser@5.53.0)(@typescript/typescript6@6.0.2)(eslint@8.34.0) '@typescript-eslint/parser': specifier: ^5.9.0 - version: 5.53.0(eslint@8.34.0)(typescript@5.8.3) + version: 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: /typescript@7.0.2 concurrently: specifier: ^7.1.0 version: 7.1.0 @@ -37,7 +40,7 @@ importers: version: 2.27.5(@typescript-eslint/parser@5.53.0)(eslint-import-resolver-typescript@2.7.1)(eslint@8.34.0) eslint-plugin-jest: specifier: ^25.3.4 - version: 25.7.0(@typescript-eslint/eslint-plugin@5.53.0)(eslint@8.34.0)(typescript@5.8.3) + version: 25.7.0(@typescript-eslint/eslint-plugin@5.53.0)(@typescript/typescript6@6.0.2)(eslint@8.34.0) eslint-plugin-json: specifier: ^3.1.0 version: 3.1.0 @@ -66,8 +69,8 @@ importers: specifier: ^4.19.2 version: 4.21.0 typescript: - specifier: ^5.8.3 - version: 5.8.3 + specifier: npm:@typescript/typescript6@^6.0.2 + version: /@typescript/typescript6@6.0.2 client: dependencies: @@ -91,13 +94,13 @@ importers: version: 11.14.1(@emotion/react@11.14.0)(@types/react@19.1.6)(react@19.1.0) '@milkdown/crepe': specifier: 7.21.2 - version: 7.21.2(typescript@5.8.3) + version: 7.21.2(typescript@7.0.2) '@milkdown/kit': specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) '@milkdown/react': specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3) + version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@7.0.2) '@milkdown/utils': specifier: 7.21.2 version: 7.21.2 @@ -208,8 +211,8 @@ importers: specifier: 7.1.34 version: 7.1.34 typescript: - specifier: ^5.8.3 - version: 5.8.3 + specifier: ^7.0.2 + version: 7.0.2 crates: {} @@ -219,8 +222,8 @@ importers: specifier: ^24.10.1 version: 24.10.1 typescript: - specifier: ^5.5.2 - version: 5.8.3 + specifier: ^7.0.2 + version: 7.0.2 wrangler: specifier: ^3.101.0 version: 3.114.17 @@ -2214,7 +2217,7 @@ packages: langium: 3.3.1 dev: false - /@milkdown/components@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): + /@milkdown/components@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2): resolution: {integrity: sha512-u3eCgRAnJgglZtJ6m5g09OF3SFPOagCe3xFvqtXRaUdX0/7E9z3hOvXkuisZNQUEF9623qwT/zNhK2rctvuFrg==} peerDependencies: '@codemirror/language': ^6 @@ -2241,7 +2244,7 @@ packages: lodash-es: 4.17.21 nanoid: 5.1.5 unist-util-visit: 5.0.0 - vue: 3.5.22(typescript@5.8.3) + vue: 3.5.22(typescript@7.0.2) transitivePeerDependencies: - supports-color - typescript @@ -2261,7 +2264,7 @@ packages: - supports-color dev: false - /@milkdown/crepe@7.21.2(typescript@5.8.3): + /@milkdown/crepe@7.21.2(typescript@7.0.2): resolution: {integrity: sha512-3BaEADeWxVYTn6TwromOKnu/m5GLJvIKGl4VlHJ9dVpdh7VLEwLbggfw5nweQmpnGWEaqyDplk2uPH0xK5MMhg==} dependencies: '@codemirror/commands': 6.8.1 @@ -2270,7 +2273,7 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.37.1 - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) '@types/lodash-es': 4.17.12 clsx: 2.1.1 codemirror: 6.0.1 @@ -2280,7 +2283,7 @@ packages: prosemirror-virtual-cursor: 0.4.2 remark-math: 6.0.0 unist-util-visit: 5.0.0 - vue: 3.5.22(typescript@5.8.3) + vue: 3.5.22(typescript@7.0.2) transitivePeerDependencies: - prosemirror-model - prosemirror-state @@ -2299,10 +2302,10 @@ packages: resolution: {integrity: sha512-/6QkN1XmEtVHe5XhPm87lCUtV9qQNRHuAhxki/wtF+10vAeq8xOBW9/S5Kv/zpS9DSgdM/Ce+BPO3+yT1FIvWg==} dev: false - /@milkdown/kit@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3): + /@milkdown/kit@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2): resolution: {integrity: sha512-93E5PgJgQXTwn0N8xSmk3Y6+oVvMMQYDV3HF22ZNE8tFVRLOz3vMLJFfxncxY0CONadCpreoOT3Ut+p9JYjySA==} dependencies: - '@milkdown/components': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/components': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) '@milkdown/core': 7.21.2 '@milkdown/ctx': 7.21.2 '@milkdown/exception': 7.21.2 @@ -2523,14 +2526,14 @@ packages: prosemirror-view: 1.41.3 dev: false - /@milkdown/react@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@5.8.3): + /@milkdown/react@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@7.0.2): resolution: {integrity: sha512-JjRFfp/K/3aatEELQNngGMnpskqvY43zAe/b9kZRSVk4Fpt4QDYep6ISFiW/46BgoUw9EsOozT6JlEvWmsVimQ==} peerDependencies: react: '*' react-dom: '*' dependencies: - '@milkdown/crepe': 7.21.2(typescript@5.8.3) - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@5.8.3) + '@milkdown/crepe': 7.21.2(typescript@7.0.2) + '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: @@ -3313,7 +3316,7 @@ packages: resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} dev: false - /@typescript-eslint/eslint-plugin@5.53.0(@typescript-eslint/parser@5.53.0)(eslint@8.34.0)(typescript@5.8.3): + /@typescript-eslint/eslint-plugin@5.53.0(@typescript-eslint/parser@5.53.0)(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-alFpFWNucPLdUOySmXCJpzr6HKC3bu7XooShWM+3w/EL6J2HIoB2PFxpLnq4JauWVk6DiVeNKzQlFEaE+X9sGw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3324,10 +3327,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/parser': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) '@typescript-eslint/scope-manager': 5.53.0 - '@typescript-eslint/type-utils': 5.53.0(eslint@8.34.0)(typescript@5.8.3) - '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/type-utils': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) + '@typescript-eslint/utils': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) debug: 4.3.4 eslint: 8.34.0 grapheme-splitter: 1.0.4 @@ -3335,26 +3338,26 @@ packages: natural-compare-lite: 1.4.0 regexpp: 3.2.0 semver: 7.7.3 - tsutils: 3.21.0(typescript@5.8.3) - typescript: 5.8.3 + tsutils: 3.21.0(@typescript/typescript6@6.0.2) + typescript: /@typescript/typescript6@6.0.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/experimental-utils@5.53.0(eslint@8.34.0)(typescript@5.8.3): + /@typescript-eslint/experimental-utils@5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-4SklZEwRn0jqkhtW+pPZpbKFXprwGneBndRM0TGzJu/LWdb9QV2hBgFIVU9AREo02BzqFvyG/ypd+xAW5YGhXw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/utils': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) eslint: 8.34.0 transitivePeerDependencies: - supports-color - typescript dev: true - /@typescript-eslint/parser@5.53.0(eslint@8.34.0)(typescript@5.8.3): + /@typescript-eslint/parser@5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-MKBw9i0DLYlmdOb3Oq/526+al20AJZpANdT6Ct9ffxcV8nKCHz63t/S0IhlTFNsBIHJv+GY5SFJ0XfqVeydQrQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3366,10 +3369,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.53.0 '@typescript-eslint/types': 5.53.0 - '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.53.0(@typescript/typescript6@6.0.2) debug: 4.3.4 eslint: 8.34.0 - typescript: 5.8.3 + typescript: /@typescript/typescript6@6.0.2 transitivePeerDependencies: - supports-color dev: true @@ -3382,7 +3385,7 @@ packages: '@typescript-eslint/visitor-keys': 5.53.0 dev: true - /@typescript-eslint/type-utils@5.53.0(eslint@8.34.0)(typescript@5.8.3): + /@typescript-eslint/type-utils@5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-HO2hh0fmtqNLzTAme/KnND5uFNwbsdYhCZghK2SoxGp3Ifn2emv+hi0PBUjzzSh0dstUIFqOj3bp0AwQlK4OWw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3392,12 +3395,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.8.3) - '@typescript-eslint/utils': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.53.0(@typescript/typescript6@6.0.2) + '@typescript-eslint/utils': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) debug: 4.3.4 eslint: 8.34.0 - tsutils: 3.21.0(typescript@5.8.3) - typescript: 5.8.3 + tsutils: 3.21.0(@typescript/typescript6@6.0.2) + typescript: /@typescript/typescript6@6.0.2 transitivePeerDependencies: - supports-color dev: true @@ -3407,7 +3410,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree@5.53.0(typescript@5.8.3): + /@typescript-eslint/typescript-estree@5.53.0(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-eKmipH7QyScpHSkhbptBBYh9v8FxtngLquq292YTEQ1pxVs39yFBlLC1xeIZcPPz1RWGqb7YgERJRGkjw8ZV7w==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3422,13 +3425,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.3 - tsutils: 3.21.0(typescript@5.8.3) - typescript: 5.8.3 + tsutils: 3.21.0(@typescript/typescript6@6.0.2) + typescript: /@typescript/typescript6@6.0.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.53.0(eslint@8.34.0)(typescript@5.8.3): + /@typescript-eslint/utils@5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-VUOOtPv27UNWLxFwQK/8+7kvxVC+hPHNsJjzlJyotlaHjLSIgOCKj9I0DBUjwOOA64qjBwx5afAPjksqOxMO0g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -3438,7 +3441,7 @@ packages: '@types/semver': 7.7.1 '@typescript-eslint/scope-manager': 5.53.0 '@typescript-eslint/types': 5.53.0 - '@typescript-eslint/typescript-estree': 5.53.0(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 5.53.0(@typescript/typescript6@6.0.2) eslint: 8.34.0 eslint-scope: 5.1.1 eslint-utils: 3.0.0(eslint@8.34.0) @@ -3456,6 +3459,173 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@typescript/typescript-aix-ppc64@7.0.2: + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + optional: true + + /@typescript/typescript-darwin-arm64@7.0.2: + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@typescript/typescript-darwin-x64@7.0.2: + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@typescript/typescript-freebsd-arm64@7.0.2: + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + + /@typescript/typescript-freebsd-x64@7.0.2: + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-arm64@7.0.2: + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-arm@7.0.2: + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-loong64@7.0.2: + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-mips64el@7.0.2: + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-ppc64@7.0.2: + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-riscv64@7.0.2: + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-s390x@7.0.2: + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-linux-x64@7.0.2: + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@typescript/typescript-netbsd-arm64@7.0.2: + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + optional: true + + /@typescript/typescript-netbsd-x64@7.0.2: + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + + /@typescript/typescript-openbsd-arm64@7.0.2: + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + optional: true + + /@typescript/typescript-openbsd-x64@7.0.2: + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + + /@typescript/typescript-sunos-x64@7.0.2: + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + + /@typescript/typescript-win32-arm64@7.0.2: + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@typescript/typescript-win32-x64@7.0.2: + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@typescript/typescript6@6.0.2: + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true + dependencies: + '@typescript/old': /typescript@6.0.3 + dev: true + /@uiw/codemirror-theme-github@4.24.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1): resolution: {integrity: sha512-S43STd3jiyd6coraYW83vb6MK9POrwDXXUV1QJ3H/nZcdG6LrWGc3zWhhJIc99dtJ7zSIDvTnUkudJ27nZ5hwA==} dependencies: @@ -3571,7 +3741,7 @@ packages: dependencies: '@vue/compiler-ssr': 3.5.22 '@vue/shared': 3.5.22 - vue: 3.5.22(typescript@5.8.3) + vue: 3.5.22(typescript@7.0.2) dev: false /@vue/shared@3.5.22: @@ -4629,7 +4799,7 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/parser': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) debug: 3.2.7 eslint: 8.34.0 eslint-import-resolver-node: 0.3.7 @@ -4659,7 +4829,7 @@ packages: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/parser': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) array-includes: 3.1.6 array.prototype.flat: 1.3.1 array.prototype.flatmap: 1.3.1 @@ -4682,7 +4852,7 @@ packages: - supports-color dev: true - /eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.53.0)(eslint@8.34.0)(typescript@5.8.3): + /eslint-plugin-jest@25.7.0(@typescript-eslint/eslint-plugin@5.53.0)(@typescript/typescript6@6.0.2)(eslint@8.34.0): resolution: {integrity: sha512-PWLUEXeeF7C9QGKqvdSbzLOiLTx+bno7/HC9eefePfEb257QFHg7ye3dh80AZVkaa/RQsBB1Q/ORQvg2X7F0NQ==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} peerDependencies: @@ -4695,8 +4865,8 @@ packages: jest: optional: true dependencies: - '@typescript-eslint/eslint-plugin': 5.53.0(@typescript-eslint/parser@5.53.0)(eslint@8.34.0)(typescript@5.8.3) - '@typescript-eslint/experimental-utils': 5.53.0(eslint@8.34.0)(typescript@5.8.3) + '@typescript-eslint/eslint-plugin': 5.53.0(@typescript-eslint/parser@5.53.0)(@typescript/typescript6@6.0.2)(eslint@8.34.0) + '@typescript-eslint/experimental-utils': 5.53.0(@typescript/typescript6@6.0.2)(eslint@8.34.0) eslint: 8.34.0 transitivePeerDependencies: - supports-color @@ -7356,14 +7526,14 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: true - /tsutils@3.21.0(typescript@5.8.3): + /tsutils@3.21.0(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 5.8.3 + typescript: /@typescript/typescript6@6.0.2 dev: true /tsx@4.21.0: @@ -7397,10 +7567,37 @@ packages: is-typed-array: 1.1.10 dev: true - /typescript@5.8.3: - resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + /typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + dev: true + + /typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 /ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -7596,7 +7793,7 @@ packages: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} dev: false - /vue@3.5.22(typescript@5.8.3): + /vue@3.5.22(typescript@7.0.2): resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==} peerDependencies: typescript: '*' @@ -7609,7 +7806,7 @@ packages: '@vue/runtime-dom': 3.5.22 '@vue/server-renderer': 3.5.22(vue@3.5.22) '@vue/shared': 3.5.22 - typescript: 5.8.3 + typescript: 7.0.2 dev: false /w3c-keyname@2.2.8: diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 7904721..12db14c 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "include": ["client/**/*.ts", "client/**/*.tsx", ".eslintrc.js", "scripts/**/*.ts"] + "include": ["client/**/*.ts", "client/**/*.tsx", ".eslintrc.js", "scripts/**/*.ts", "github-auth-worker/**/*.ts"] } diff --git a/tsconfig.json b/tsconfig.json index b32c38f..6a940a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,10 +7,8 @@ "outDir": "dist", "removeComments": true, "strict": true, - "moduleResolution": "node", "esModuleInterop": true, "experimentalDecorators": true, - "baseUrl": "./", "lib": ["esnext", "dom", "DOM.Iterable"] } } From 84cc272777b8f670ae765c6ad7aee832c8319e85 Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 20 Aug 2026 15:59:43 +0800 Subject: [PATCH 03/17] chore: use node v22 --- .github/workflows/build.yml | 8 ++++---- github-auth-worker/package.json | 3 +++ package.json | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6fa6a7..a308245 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,7 +28,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -66,7 +66,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -106,7 +106,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 @@ -143,7 +143,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '22' - name: Install pnpm uses: pnpm/action-setup@v2 diff --git a/github-auth-worker/package.json b/github-auth-worker/package.json index ae0644a..ff1f2f9 100644 --- a/github-auth-worker/package.json +++ b/github-auth-worker/package.json @@ -2,6 +2,9 @@ "name": "github-auth-worker", "version": "0.0.0", "private": true, + "engines": { + "node": ">=22 <23" + }, "scripts": { "deploy": "wrangler deploy", "dev": "wrangler dev", diff --git a/package.json b/package.json index 86e74ed..537b15f 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "description": "", "license": "ISC", "main": "server.js", + "engines": { + "node": ">=22 <23" + }, "scripts": { "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:client\"", "dev:server": "pnpm --filter crates dev", From afc9285b06de30b06028b79036da62c6a223a2ab Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 20 Aug 2026 18:02:09 +0800 Subject: [PATCH 04/17] feat: support to login with github account --- client/src/components/Header/Header.scss | 12 ++ client/src/components/Header/Header.tsx | 22 ++++ client/src/utils/hooks/githubAuthHooks.ts | 153 ++++++++++++++++++++++ github-auth-worker/package.json | 7 +- github-auth-worker/src/index.ts | 109 ++++++++++++++- github-auth-worker/src/oauthRelay.ts | 113 ++++++++++++++++ github-auth-worker/wrangler.jsonc | 3 + pnpm-lock.yaml | 8 ++ 8 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 client/src/utils/hooks/githubAuthHooks.ts create mode 100644 github-auth-worker/src/oauthRelay.ts diff --git a/client/src/components/Header/Header.scss b/client/src/components/Header/Header.scss index 2c92022..53baf30 100644 --- a/client/src/components/Header/Header.scss +++ b/client/src/components/Header/Header.scss @@ -23,4 +23,16 @@ .icon-wrapper { margin-left: 0.8rem; } + + .github-avatar-link { + display: flex; + margin-left: 0.8rem; + } + + .github-avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; + } } diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index 0524f17..ec6b466 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -10,6 +10,7 @@ import { ImgManagement } from '../ImgManagement/ImgManagement'; import { Icon } from '@/components/Icon/Icon'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; import { selectGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc, useSwitchReadonlyMode, useSwitchTheme } from '@/utils/hooks/reduxHooks'; import { nextTick } from '@/utils/utils'; @@ -21,6 +22,7 @@ export default function Header() { const { isDirty, type, contentIdent } = useSelector(selectCurDoc); const themeMenuRef = useRef(null); + const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, startGitHubLogin } = useGitHubLogin(); const saveDoc = useSaveDoc(); const switchReadonlyMode = useSwitchReadonlyMode(); @@ -99,6 +101,26 @@ export default function Header() { themeMenuRef.current?.toggle(e); }} /> + {githubLogin && githubAvatarUrl ? ( + + {`${githubLogin}'s + + ) : ( + + )} diff --git a/client/src/utils/hooks/githubAuthHooks.ts b/client/src/utils/hooks/githubAuthHooks.ts new file mode 100644 index 0000000..1ed689c --- /dev/null +++ b/client/src/utils/hooks/githubAuthHooks.ts @@ -0,0 +1,153 @@ +import { useEffect, useRef, useState } from 'react'; + +const GITHUB_OAUTH_WORKER_URL = 'https://markdown-editor-github-auth.s-elo.workers.dev/'; +const GITHUB_ACCESS_TOKEN_STORAGE_KEY = 'github-access-token'; +const GITHUB_OAUTH_STATE_STORAGE_KEY = 'github-oauth-state'; +const OAUTH_QUERY_PARAMS = ['code', 'state', 'error', 'error_description']; + +interface GitHubTokenResponse { + token?: string; + error?: string; +} + +interface GitHubUserResponse { + login?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention + avatar_url?: string; +} + +interface GitHubUser { + login: string; + avatarUrl: string | null; +} + +const getUrlWithoutOAuthParams = () => { + const url = new URL(window.location.href); + OAUTH_QUERY_PARAMS.forEach((param) => { + url.searchParams.delete(param); + }); + return url; +}; + +const removeOAuthParamsFromUrl = () => { + const url = getUrlWithoutOAuthParams(); + window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`); +}; + +const getGitHubUser = async (token: string): Promise => { + const response = await fetch('https://api.github.com/user', { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + return null; + } + + const user = (await response.json()) as GitHubUserResponse; + return user.login ? { login: user.login, avatarUrl: user.avatar_url ?? null } : null; +}; + +/** Manages the GitHub OAuth callback and persisted access token. */ +export const useGitHubLogin = () => { + const oauthInProgressRef = useRef(false); + const [githubLogin, setGithubLogin] = useState(null); + const [githubAvatarUrl, setGithubAvatarUrl] = useState(null); + const [isGitHubLoginLoading, setIsGitHubLoginLoading] = useState(false); + + useEffect(() => { + const url = new URL(window.location.href); + const code = url.searchParams.get('code'); + const oauthError = url.searchParams.get('error'); + const oauthState = url.searchParams.get('state'); + + const restoreStoredSession = async () => { + const token = window.localStorage.getItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); + if (!token) { + return; + } + + try { + const user = await getGitHubUser(token); + if (user) { + setGithubLogin(user.login); + setGithubAvatarUrl(user.avatarUrl); + } else { + // GitHub returns a non-success response for expired or revoked tokens. + window.localStorage.removeItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); + } + } catch { + // Keep the token while offline; validate it again on the next app load. + } + }; + + const completeOAuthLogin = async (authorizationCode: string) => { + oauthInProgressRef.current = true; + setIsGitHubLoginLoading(true); + removeOAuthParamsFromUrl(); + + try { + const response = await fetch(GITHUB_OAUTH_WORKER_URL, { + method: 'POST', + mode: 'cors', + // eslint-disable-next-line @typescript-eslint/naming-convention + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: authorizationCode }), + }); + const result = (await response.json()) as GitHubTokenResponse; + + if (!response.ok || !result.token) { + throw new Error(result.error ?? 'GitHub authentication failed.'); + } + + const user = await getGitHubUser(result.token); + if (!user) { + throw new Error('GitHub returned an invalid access token.'); + } + + window.localStorage.setItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY, result.token); + setGithubLogin(user.login); + setGithubAvatarUrl(user.avatarUrl); + } catch (error) { + const message = error instanceof Error ? error.message : 'GitHub authentication failed.'; + window.alert(message); + } finally { + setIsGitHubLoginLoading(false); + } + }; + + if (code && !oauthInProgressRef.current) { + const expectedOAuthState = window.sessionStorage.getItem(GITHUB_OAUTH_STATE_STORAGE_KEY); + window.sessionStorage.removeItem(GITHUB_OAUTH_STATE_STORAGE_KEY); + + if (!oauthState || oauthState !== expectedOAuthState) { + removeOAuthParamsFromUrl(); + window.alert('GitHub authentication failed because the OAuth state was invalid.'); + } else { + void completeOAuthLogin(code); + } + } else if (oauthError) { + window.sessionStorage.removeItem(GITHUB_OAUTH_STATE_STORAGE_KEY); + removeOAuthParamsFromUrl(); + window.alert(`GitHub authentication was cancelled: ${oauthError}`); + } else { + void restoreStoredSession(); + } + }, []); + + return { + githubLogin, + githubAvatarUrl, + isGitHubLoginLoading, + startGitHubLogin: () => { + const oauthState = window.crypto.randomUUID(); + const workerUrl = new URL(GITHUB_OAUTH_WORKER_URL); + workerUrl.searchParams.set('return_to', getUrlWithoutOAuthParams().toString()); + workerUrl.searchParams.set('client_state', oauthState); + window.sessionStorage.setItem(GITHUB_OAUTH_STATE_STORAGE_KEY, oauthState); + window.location.assign(workerUrl.toString()); + }, + }; +}; diff --git a/github-auth-worker/package.json b/github-auth-worker/package.json index ff1f2f9..a79be25 100644 --- a/github-auth-worker/package.json +++ b/github-auth-worker/package.json @@ -14,8 +14,11 @@ "type-check:full": "pnpm cf-typegen && pnpm type-check" }, "devDependencies": { + "@types/node": "^24.10.1", "typescript": "^7.0.2", - "wrangler": "^3.101.0", - "@types/node": "^24.10.1" + "wrangler": "^3.101.0" + }, + "dependencies": { + "jose": "^6.2.8" } } diff --git a/github-auth-worker/src/index.ts b/github-auth-worker/src/index.ts index f0ba832..dcc4b64 100644 --- a/github-auth-worker/src/index.ts +++ b/github-auth-worker/src/index.ts @@ -1,3 +1,8 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { completeGitHubOAuthRedirect, getOAuthCallbackUrl, startGitHubOAuth, type OAuthRelayConfig } from './oauthRelay'; + /** * Welcome to Cloudflare Workers! This is your first worker. * @@ -11,8 +16,108 @@ * Learn more at https://developers.cloudflare.com/workers/ */ +interface Env { + CLIENT_ID: string; + CLIENT_SECRET: string; + ALLOWED_REDIRECT_ORIGINS: string; +} + +interface OAuthCodeRequest { + code: string; +} + +interface GitHubAccessTokenSuccess { + access_token: string; +} + +interface GitHubAccessTokenFailure { + error: string; + error_description?: string; + error_uri?: string; +} + +type GitHubAccessTokenResponse = GitHubAccessTokenFailure | GitHubAccessTokenSuccess; + +function isOAuthCodeRequest(value: unknown): value is OAuthCodeRequest { + return typeof value === 'object' && value !== null && 'code' in value && typeof value.code === 'string' && value.code.length > 0; +} + +function isGitHubAccessTokenFailure(value: GitHubAccessTokenResponse): value is GitHubAccessTokenFailure { + return 'error' in value; +} + +function getOAuthRelayConfig(env: Env): OAuthRelayConfig { + return { + allowedRedirectOrigins: env.ALLOWED_REDIRECT_ORIGINS, + clientId: env.CLIENT_ID, + stateSecret: env.CLIENT_SECRET, + }; +} + export default { - fetch(): Response { - return new Response('Hello World!'); + async fetch(request: Request, env: Env): Promise { + const requestUrl = new URL(request.url); + + // handle CORS pre-flight request + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + }, + }); + } + + // redirect GET requests to the OAuth login page on github.com + if (request.method === 'GET') { + const relayConfig = getOAuthRelayConfig(env); + if (requestUrl.searchParams.has('code') || requestUrl.searchParams.has('error')) { + return completeGitHubOAuthRedirect(requestUrl, relayConfig); + } + + return startGitHubOAuth(requestUrl, relayConfig); + } + + try { + const body: unknown = await request.json(); + if (!isOAuthCodeRequest(body)) { + return new Response('A GitHub authorization code is required.', { status: 400 }); + } + + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'user-agent': 'cloudflare-worker-github-oauth-login-demo', + accept: 'application/json', + }, + body: JSON.stringify({ + client_id: env.CLIENT_ID, + client_secret: env.CLIENT_SECRET, + code: body.code, + redirect_uri: getOAuthCallbackUrl(requestUrl), + }), + }); + const result = (await response.json()) as GitHubAccessTokenResponse; + const headers = { + 'Access-Control-Allow-Origin': '*', + }; + + if (isGitHubAccessTokenFailure(result)) { + return new Response(JSON.stringify(result), { status: 401, headers }); + } + + return new Response(JSON.stringify({ token: result.access_token }), { + status: 201, + headers, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unexpected error.'; + console.error(error); + return new Response(message, { + status: 500, + }); + } }, } satisfies ExportedHandler; diff --git a/github-auth-worker/src/oauthRelay.ts b/github-auth-worker/src/oauthRelay.ts new file mode 100644 index 0000000..1b8f898 --- /dev/null +++ b/github-auth-worker/src/oauthRelay.ts @@ -0,0 +1,113 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +import { jwtVerify, SignJWT, type JWTPayload } from 'jose'; + +export interface OAuthRelayConfig { + allowedRedirectOrigins: string; + clientId: string; + stateSecret: string; +} + +interface OAuthState extends JWTPayload { + clientState: string; + returnTo: string; +} + +const REDIRECT_CODE = 302; +const textEncoder = new TextEncoder(); + +async function createOAuthState(returnTo: string, clientState: string, secret: string): Promise { + return new SignJWT({ clientState, returnTo } satisfies OAuthState) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuedAt() + .setExpirationTime('10m') + .setJti(crypto.randomUUID()) + .sign(textEncoder.encode(secret)); +} + +function isOAuthState(value: unknown): value is OAuthState { + return ( + typeof value === 'object' && + value !== null && + 'clientState' in value && + typeof value.clientState === 'string' && + 'exp' in value && + typeof value.exp === 'number' && + 'jti' in value && + typeof value.jti === 'string' && + 'returnTo' in value && + typeof value.returnTo === 'string' + ); +} + +async function readOAuthState(value: string, secret: string): Promise { + try { + const { payload } = await jwtVerify(value, textEncoder.encode(secret), { algorithms: ['HS256'] }); + return isOAuthState(payload) ? payload : null; + } catch { + return null; + } +} + +function getAllowedReturnUrl(value: string, allowedOrigins: string): URL | null { + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + const isAllowed = allowedOrigins + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean) + .some((origin) => new URL(origin).origin === url.origin); + return isAllowed ? url : null; + } catch { + return null; + } +} + +export function getOAuthCallbackUrl(requestUrl: URL): string { + return `${requestUrl.origin}${requestUrl.pathname}`; +} + +export async function startGitHubOAuth(requestUrl: URL, config: OAuthRelayConfig): Promise { + const returnToValue = requestUrl.searchParams.get('return_to'); + const clientState = requestUrl.searchParams.get('client_state'); + if (!returnToValue || !clientState) { + return new Response('A return URL and client state are required.', { status: 400 }); + } + + const returnTo = getAllowedReturnUrl(returnToValue, config.allowedRedirectOrigins); + if (!returnTo) { + return new Response('The return URL is not allowed.', { status: 400 }); + } + + const authorizeUrl = new URL('https://github.com/login/oauth/authorize'); + authorizeUrl.searchParams.set('client_id', config.clientId); + authorizeUrl.searchParams.set('redirect_uri', getOAuthCallbackUrl(requestUrl)); + authorizeUrl.searchParams.set('state', await createOAuthState(returnTo.toString(), clientState, config.stateSecret)); + return Response.redirect(authorizeUrl.toString(), REDIRECT_CODE); +} + +export async function completeGitHubOAuthRedirect(requestUrl: URL, config: OAuthRelayConfig): Promise { + const stateValue = requestUrl.searchParams.get('state'); + const state = stateValue ? await readOAuthState(stateValue, config.stateSecret) : null; + if (!state) { + return new Response('The OAuth state is missing, invalid, or expired.', { status: 400 }); + } + + const returnTo = getAllowedReturnUrl(state.returnTo, config.allowedRedirectOrigins); + if (!returnTo) { + return new Response('The return URL is not allowed.', { status: 400 }); + } + + for (const parameter of ['code', 'error', 'error_description']) { + const value = requestUrl.searchParams.get(parameter); + if (value) { + returnTo.searchParams.set(parameter, value); + } + } + returnTo.searchParams.set('state', state.clientState); + return Response.redirect(returnTo.toString(), REDIRECT_CODE); +} diff --git a/github-auth-worker/wrangler.jsonc b/github-auth-worker/wrangler.jsonc index b89dd64..48987d1 100644 --- a/github-auth-worker/wrangler.jsonc +++ b/github-auth-worker/wrangler.jsonc @@ -2,6 +2,9 @@ "name": "markdown-editor-github-auth", "main": "src/index.ts", "compatibility_date": "2025-07-18", + "vars": { + "ALLOWED_REDIRECT_ORIGINS": "https://s-elo.github.io,http://localhost:4000,http://127.0.0.1:4000" + }, "observability": { "enabled": true } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c573ef..73cc5ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,10 @@ importers: crates: {} github-auth-worker: + dependencies: + jose: + specifier: ^6.2.8 + version: 6.2.8 devDependencies: '@types/node': specifier: ^24.10.1 @@ -5602,6 +5606,10 @@ packages: hasBin: true dev: true + /jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} + dev: false + /js-sdsl@4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} dev: true From 857a4ae7ac10d78b3b33f98e5c1e4f5378f4f030 Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 20 Aug 2026 18:14:17 +0800 Subject: [PATCH 05/17] feat: support to logout github and select account --- client/src/components/Header/Header.scss | 44 ++++++++++++++++++++++- client/src/components/Header/Header.tsx | 28 +++++++++------ client/src/utils/hooks/githubAuthHooks.ts | 8 +++++ github-auth-worker/src/oauthRelay.ts | 1 + 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/client/src/components/Header/Header.scss b/client/src/components/Header/Header.scss index 53baf30..d2a49b1 100644 --- a/client/src/components/Header/Header.scss +++ b/client/src/components/Header/Header.scss @@ -24,9 +24,21 @@ margin-left: 0.8rem; } + .github-account { + position: relative; + margin-left: 0.8rem; + + &:hover, + &:focus-within { + .github-logout-popup { + visibility: visible; + opacity: 1; + } + } + } + .github-avatar-link { display: flex; - margin-left: 0.8rem; } .github-avatar { @@ -35,4 +47,34 @@ border-radius: 50%; object-fit: cover; } + + .github-logout-popup { + position: absolute; + top: 100%; + right: 0; + visibility: hidden; + opacity: 0; + padding-top: 0.35rem; + transition: opacity 0.15s ease-in-out; + } + + .github-logout-button { + display: flex; + gap: 0.4rem; + align-items: center; + padding: 0.45rem 0.65rem; + color: $headerTextColor; + font-size: 0.8rem; + white-space: nowrap; + cursor: pointer; + background: $panelBackgroundColor; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + box-shadow: $frameShadow; + + &:hover, + &:focus-visible { + background: $hoverBgcColor; + } + } } diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index ec6b466..e2f2956 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -22,7 +22,7 @@ export default function Header() { const { isDirty, type, contentIdent } = useSelector(selectCurDoc); const themeMenuRef = useRef(null); - const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, startGitHubLogin } = useGitHubLogin(); + const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, logoutGitHub, startGitHubLogin } = useGitHubLogin(); const saveDoc = useSaveDoc(); const switchReadonlyMode = useSwitchReadonlyMode(); @@ -102,15 +102,23 @@ export default function Header() { }} /> {githubLogin && githubAvatarUrl ? ( - - {`${githubLogin}'s - +
+ + {`${githubLogin}'s + +
+ +
+
) : ( { githubLogin, githubAvatarUrl, isGitHubLoginLoading, + logoutGitHub: () => { + window.localStorage.removeItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); + window.sessionStorage.removeItem(GITHUB_OAUTH_STATE_STORAGE_KEY); + setGithubLogin(null); + setGithubAvatarUrl(null); + setIsGitHubLoginLoading(false); + oauthInProgressRef.current = false; + }, startGitHubLogin: () => { const oauthState = window.crypto.randomUUID(); const workerUrl = new URL(GITHUB_OAUTH_WORKER_URL); diff --git a/github-auth-worker/src/oauthRelay.ts b/github-auth-worker/src/oauthRelay.ts index 1b8f898..f18390e 100644 --- a/github-auth-worker/src/oauthRelay.ts +++ b/github-auth-worker/src/oauthRelay.ts @@ -86,6 +86,7 @@ export async function startGitHubOAuth(requestUrl: URL, config: OAuthRelayConfig const authorizeUrl = new URL('https://github.com/login/oauth/authorize'); authorizeUrl.searchParams.set('client_id', config.clientId); authorizeUrl.searchParams.set('redirect_uri', getOAuthCallbackUrl(requestUrl)); + authorizeUrl.searchParams.set('prompt', 'select_account'); authorizeUrl.searchParams.set('state', await createOAuthState(returnTo.toString(), clientState, config.stateSecret)); return Response.redirect(authorizeUrl.toString(), REDIRECT_CODE); } From 363ebdf7c17c32b881f1a9ecc3b4c5c06e5f2bed Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 21 Aug 2026 17:28:25 +0800 Subject: [PATCH 06/17] feat: use github api for doc mangement(buggy version) --- client/package.json | 1 + client/src/App.tsx | 16 +- client/src/components/Editor/DraftEditor.tsx | 1 + client/src/components/Editor/Editor.tsx | 47 +- .../components/Editor/configs/uploadConfig.ts | 3 + client/src/components/GitBox/CommitMsgBox.tsx | 39 ++ client/src/components/GitBox/GitBox.scss | 29 +- client/src/components/GitBox/GitBox.tsx | 38 +- .../components/GitBox/GitHubChangesBox.tsx | 238 ++++++++++ client/src/components/Header/Header.tsx | 10 +- client/src/components/Menu/Menu.tsx | 38 +- client/src/components/Menu/operations.tsx | 139 +++--- client/src/components/OpenTab/OpenTab.tsx | 11 +- .../components/Settings/GitHubSettings.tsx | 228 +++++++++ client/src/components/Settings/Settings.scss | 23 +- client/src/components/Settings/Settings.tsx | 121 +++-- client/src/components/Sidebar/Sidebar.tsx | 24 +- client/src/redux-api/github.ts | 380 +++++++++++++++ client/src/redux-feature/curDocSlice.ts | 30 +- .../src/redux-feature/githubWorkspaceSlice.ts | 278 +++++++++++ client/src/store.ts | 51 +- client/src/theme/cyber.scss | 120 ++--- client/src/utils/githubWorkspace.ts | 101 ++++ client/src/utils/githubWorkspaceDb.ts | 51 ++ client/src/utils/hooks/githubAuthHooks.ts | 20 +- client/src/utils/hooks/reduxHooks.ts | 111 +++-- client/src/utils/hooks/workspaceHooks.ts | 443 ++++++++++++++++++ github-auth-worker/src/index.ts | 8 +- github-auth-worker/src/oauthRelay.ts | 22 +- github-auth-worker/wrangler.jsonc | 1 + pnpm-lock.yaml | 133 ++++++ 31 files changed, 2411 insertions(+), 344 deletions(-) create mode 100644 client/src/components/GitBox/CommitMsgBox.tsx create mode 100644 client/src/components/GitBox/GitHubChangesBox.tsx create mode 100644 client/src/components/Settings/GitHubSettings.tsx create mode 100644 client/src/redux-api/github.ts create mode 100644 client/src/redux-feature/githubWorkspaceSlice.ts create mode 100644 client/src/utils/githubWorkspace.ts create mode 100644 client/src/utils/githubWorkspaceDb.ts create mode 100644 client/src/utils/hooks/workspaceHooks.ts diff --git a/client/package.json b/client/package.json index bb58abf..4d58b2f 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "@milkdown/react": "7.21.2", "@milkdown/utils": "7.21.2", "@mui/icons-material": "^7.2.0", + "@octokit/rest": "^22.0.1", "@reduxjs/toolkit": "^1.7.1", "@uiw/codemirror-theme-github": "^4.24.2", "@uiw/react-codemirror": "4.5.3", diff --git a/client/src/App.tsx b/client/src/App.tsx index 1c48038..4e85ae0 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,32 +11,36 @@ import { Menu } from './components/Menu/Menu'; import { Sidebar } from './components/Sidebar/Sidebar'; import { SplitBar } from './components/SplitBar'; import { APP_VERSION } from './constants'; +import { selectWorkspaceMode } from './redux-feature/githubWorkspaceSlice'; import { selectMenuCollapse } from './redux-feature/globalOptsSlice'; import { useCheckServer, useWarnUnsavedOnUnload } from './utils/hooks/reduxHooks'; +import { useGitHubWorkspaceSync } from './utils/hooks/workspaceHooks'; import './App.scss'; export const App: FC = () => { useWarnUnsavedOnUnload(); - const { isLoading, isError, isSuccess, data: serverCheckRes } = useCheckServer(); + const workspaceMode = useSelector(selectWorkspaceMode); + useGitHubWorkspaceSync(); + const { isLoading, isError, isSuccess, data: serverCheckRes } = useCheckServer(workspaceMode === 'local'); const menuCollapse = useSelector(selectMenuCollapse); const navigate = useNavigate(); - const showMenu = !isError && !menuCollapse; + const showMenu = (workspaceMode === 'github' || !isError) && !menuCollapse; useEffect(() => { - if (isError) { + if (workspaceMode === 'local' && isError) { void navigate('/internal/guide'); return; } - if (!isLoading && serverCheckRes?.version !== APP_VERSION) { + if (workspaceMode === 'local' && !isLoading && serverCheckRes?.version !== APP_VERSION) { void navigate('/internal/version-mismatch'); } - }, [isError, isSuccess, serverCheckRes]); + }, [isError, isSuccess, serverCheckRes, workspaceMode]); - if (isLoading) { + if (workspaceMode === 'local' && isLoading) { return (
diff --git a/client/src/components/Editor/DraftEditor.tsx b/client/src/components/Editor/DraftEditor.tsx index e803db3..1736071 100644 --- a/client/src/components/Editor/DraftEditor.tsx +++ b/client/src/components/Editor/DraftEditor.tsx @@ -132,6 +132,7 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, contentIdent: docId, title: doc?.title, headings, + syncTab: false, type, }), ); diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 401cb90..39c2e83 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -10,11 +10,12 @@ import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; import { searchAndHighlight } from './mountedAddons'; import { EditorRef } from './type'; -import { useGetDocQuery } from '@/redux-api/docs'; import { useGetSettingsQuery } from '@/redux-api/settings'; import { updateCurDoc, selectCurDoc, selectCurTabs, clearCurDoc } from '@/redux-feature/curDocSlice'; import { clearDraft, selectDraft, setDraft } from '@/redux-feature/draftsSlice'; +import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; import { selectNarrowMode, selectReadonly, selectTheme } from '@/redux-feature/globalOptsSlice'; +import { useWorkspaceDocQuery } from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; import { getDraftKey, normalizePath, normalizeEOL } from '@/utils/utils'; @@ -24,6 +25,7 @@ import './Editor.scss'; const SEARCH_HIGHLIGHT_DELAY_SAME_DOC = 50; const SEARCH_HIGHLIGHT_DELAY_NEW_DOC = 200; +const GITHUB_MOVE_NAVIGATION_GRACE_PERIOD = 250; const getDefaultDoc = () => ({ content: 'Loading...', @@ -43,11 +45,14 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } // useGetDocQuery will be cached (within a limited time) according to different contentPath // with auto refetch when the doc is updated - const { data: fetchedDoc = getDefaultDoc(), isSuccess, error } = useGetDocQuery(curDocPath); - const { data: settings } = useGetSettingsQuery(); + const githubWorkspace = useSelector(selectGithubWorkspace); + const { data: fetchedDoc = getDefaultDoc(), isSuccess, error } = useWorkspaceDocQuery(curDocPath); + const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const { content: storedContent, contentIdent: storedContentPath } = useSelector(selectCurDoc); - const draftKey = getDraftKey(settings?.docRootPath, curDocPath); + const workspaceKey = + githubWorkspace.mode === 'github' ? getGitHubWorkspaceKey(githubWorkspace.config) : settings?.docRootPath; + const draftKey = getDraftKey(workspaceKey, curDocPath); const draft = useSelector(selectDraft(draftKey)); const theme = useSelector(selectTheme); const readonly = useSelector(selectReadonly); @@ -123,13 +128,34 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } }, []); useEffect(() => { - if (error) { - void navigate('/'); - return Toast.error((error as unknown as Error).message ?? 'Failed to fetch doc'); + if (!error) return; + + // A GitHub move updates the overlay and tab list synchronously, while React Router may commit the new URL on a + // later render. If the active tab already has the destination path, finish that pending navigation instead of + // treating the temporarily tombstoned source URL as a genuinely missing document. + if (githubWorkspace.mode === 'github' && storedContentPath === curDocPath) { + const activeWorkspaceTab = curTabs.find((tab) => tab.active && tab.type === 'workspace'); + if (activeWorkspaceTab && activeWorkspaceTab.ident !== curDocPath) { + void navigate(`/article/${activeWorkspaceTab.ident}`, { replace: true }); + return; + } } - }, [error]); - // when switching the doc (or same doc refetched) + // Keep genuine local errors immediate. GitHub gets a short grace period so a tab/route update dispatched in the + // same move cannot lose a race to this error effect. + const timeout = window.setTimeout( + () => { + void navigate('/'); + Toast.error((error as unknown as Error).message ?? 'Failed to fetch doc'); + }, + githubWorkspace.mode === 'github' ? GITHUB_MOVE_NAVIGATION_GRACE_PERIOD : 0, + ); + return () => { + window.clearTimeout(timeout); + }; + }, [curDocPath, curTabs, error, githubWorkspace.mode, navigate, storedContentPath]); + + // when switching the doc (or same doc re-fetched) useEffect(() => { if (!isSuccess) { return; @@ -153,7 +179,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } ); crepeEditorRef.current?.reRender(); - }, [fetchedDoc]); + }, [fetchedDoc?.content, fetchedDoc?.filePath]); const onUpdated = (ctx: Ctx, markdown: string) => { const isDirty = normalizeEOL(markdown) !== normalizeEOL(fetchedDoc?.content ?? ''); @@ -165,6 +191,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } isDirty, contentIdent: curDocPath, headings, + syncTab: false, type: 'workspace', }), ); diff --git a/client/src/components/Editor/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 21d9696..339af63 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -14,6 +14,9 @@ export function getImageUrl(url: string) { } export async function uploadImage(file: File) { + if (window.localStorage.getItem('workspace-mode') === 'github') { + throw new Error('Image uploads are not available in GitHub workspace mode yet.'); + } const formData = new FormData(); formData.append('file', file); const res = await fetch(`${SERVER_BASE_URL}/imgs/upload`, { diff --git a/client/src/components/GitBox/CommitMsgBox.tsx b/client/src/components/GitBox/CommitMsgBox.tsx new file mode 100644 index 0000000..dcec910 --- /dev/null +++ b/client/src/components/GitBox/CommitMsgBox.tsx @@ -0,0 +1,39 @@ +import { InputText } from 'primereact/inputtext'; +import { InputTextarea } from 'primereact/inputtextarea'; +import { FC, useState } from 'react'; + +interface CommitMsgBoxProps { + onCommitMsgTitleChange: (commitMsgTitle: string) => void; + onCommitMsgBodyChange: (commitMsgBody: string) => void; +} + +export const CommitMsgBox: FC = ({ onCommitMsgTitleChange, onCommitMsgBodyChange }) => { + const [commitMsgTitle, setCommitMsgTitle] = useState(''); + const [commitMsgBody, setCommitMsgBody] = useState(''); + + return ( +
+
Title
+ { + setCommitMsgTitle(event.target.value); + onCommitMsgTitleChange(event.target.value); + }} + className="commit-msg-input" + placeholder="commit message title" + /> +
Body
+ { + setCommitMsgBody(event.target.value); + onCommitMsgBodyChange(event.target.value); + }} + className="commit-msg-input" + placeholder="commit message body" + /> +
+ ); +}; diff --git a/client/src/components/GitBox/GitBox.scss b/client/src/components/GitBox/GitBox.scss index 714a0e0..241fc88 100644 --- a/client/src/components/GitBox/GitBox.scss +++ b/client/src/components/GitBox/GitBox.scss @@ -1,4 +1,4 @@ -@use "@/utils/utils.scss" as *; +@use '@/utils/utils.scss' as *; .git-box { .no-git-service { @@ -152,7 +152,7 @@ position: relative; &:hover { &::after { - content: ""; + content: ''; position: absolute; left: 0; top: 0; @@ -167,6 +167,7 @@ } .commit-msg-box { + margin-top: 1rem; div { font-weight: bold; margin: 1rem 0 0.5rem 0; @@ -184,3 +185,27 @@ min-height: 200px; } } + +.github-changes-box { + .op-box > button, + .github-change-action { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 0.4rem; + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + } + .github-change-actions { + display: flex; + align-items: center; + } + .no-git-service { + height: auto; + padding: 0.5rem; + color: #c0392b; + } +} diff --git a/client/src/components/GitBox/GitBox.tsx b/client/src/components/GitBox/GitBox.tsx index d241bef..0bae310 100644 --- a/client/src/components/GitBox/GitBox.tsx +++ b/client/src/components/GitBox/GitBox.tsx @@ -9,11 +9,10 @@ import CloudUploadOutlinedIcon from '@mui/icons-material/CloudUploadOutlined'; import FileOpenIcon from '@mui/icons-material/FileOpenOutlined'; import RemoveIcon from '@mui/icons-material/RemoveOutlined'; import UndoIcon from '@mui/icons-material/UndoOutlined'; -import { InputText } from 'primereact/inputtext'; -import { InputTextarea } from 'primereact/inputtextarea'; import { ProgressSpinner } from 'primereact/progressspinner'; import React, { FC, useCallback, useState } from 'react'; +import { CommitMsgBox } from '@/components/GitBox/CommitMsgBox'; import { Icon } from '@/components/Icon/Icon'; import { useGetDocSubItemsQuery } from '@/redux-api/docs'; import { @@ -39,41 +38,6 @@ const defaultStatus = { noGit: true, }; -interface CommitMsgBoxProps { - onCommitMsgTitleChange: (commitMsgTitle: string) => void; - onCommitMsgBodyChange: (commitMsgBody: string) => void; -} -const CommitMsgBox: FC = ({ onCommitMsgTitleChange, onCommitMsgBodyChange }) => { - const [commitMsgTitle, setCommitMsgTitle] = useState(''); - const [commitMsgBody, setCommitMsgBody] = useState(''); - - return ( -
-
Title
- { - setCommitMsgTitle(e.target.value); - onCommitMsgTitleChange(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message title" - /> -
Body
- { - setCommitMsgBody(e.target.value); - onCommitMsgBodyChange(e.target.value); - }} - className="commit-msg-input" - placeholder="commit message body" - /> -
- ); -}; - export const GitBox: FC = () => { const { navigate, curPath } = useCurPath(); diff --git a/client/src/components/GitBox/GitHubChangesBox.tsx b/client/src/components/GitBox/GitHubChangesBox.tsx new file mode 100644 index 0000000..89f8c5d --- /dev/null +++ b/client/src/components/GitBox/GitHubChangesBox.tsx @@ -0,0 +1,238 @@ +import { FC, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import type { AppDispatch } from '@/store'; + +import { CommitMsgBox } from '@/components/GitBox/CommitMsgBox'; +import { githubApi, usePublishGitHubWorkspaceMutation } from '@/redux-api/github'; +import { + applyPublishedWorkspace, + discardAllChanges, + discardOperations, + markRemoteStale, + rebaseRemoteWorkspace, + selectGithubWorkspace, + stageAllOperations, + stageOperations, + unstageOperations, + type GitHubOverlayEntry, +} from '@/redux-feature/githubWorkspaceSlice'; +import Toast from '@/utils/Toast'; +import { confirm } from '@/utils/utils'; + +import './GitBox.scss'; + +interface ChangeGroup { + id: string; + label: string; + entries: GitHubOverlayEntry[]; +} + +const groupChanges = (entries: GitHubOverlayEntry[]) => { + const groups = new Map(); + entries.forEach((entry) => { + const group = groups.get(entry.operationId) ?? { id: entry.operationId, label: entry.label, entries: [] }; + group.entries.push(entry); + groups.set(entry.operationId, group); + }); + return [...groups.values()]; +}; + +const ChangeList: FC<{ + groups: ChangeGroup[]; + actionIcon: string; + actionTitle: string; + onAction: (id: string) => void; + onRevert?: (id: string) => void; +}> = ({ groups, actionIcon, actionTitle, onAction, onRevert }) => + groups.length ? ( +
    + {groups.map((group) => ( +
  • +
    entry.path).join('\n')}> + {group.label} +
    +
    + {onRevert && ( + + )} + +
    +
  • + ))} +
+ ) : ( +
+ + No changes +
+ ); + +export const GitHubChangesBox: FC = () => { + const dispatch = useDispatch(); + const workspace = useSelector(selectGithubWorkspace); + const [publishing, setPublishing] = useState(false); + const [publish] = usePublishGitHubWorkspaceMutation(); + const stagedOperationIds = useMemo( + () => new Set(Object.values(workspace.staged).map((entry) => entry.operationId)), + [workspace.staged], + ); + const workingGroups = useMemo( + () => groupChanges(Object.values(workspace.working).filter((entry) => !stagedOperationIds.has(entry.operationId))), + [stagedOperationIds, workspace.working], + ); + const stagedGroups = useMemo(() => groupChanges(Object.values(workspace.staged)), [workspace.staged]); + + const publishChanges = async () => { + if (!stagedGroups.length || workspace.remoteStale) return; + let title = ''; + let body = ''; + const accepted = await confirm({ + message: ( + (title = commitTitle)} + onCommitMsgBodyChange={(commitBody) => (body = commitBody)} + /> + ), + acceptLabel: 'Publish', + }); + if (!accepted) return; + if (!title.trim()) { + Toast.warn('Commit title cannot be blank'); + return; + } + try { + setPublishing(true); + const snapshot = await publish({ + config: workspace.config, + staged: Object.values(workspace.staged), + title: title.trim(), + body, + }).unwrap(); + dispatch(applyPublishedWorkspace(snapshot)); + Toast('Published to GitHub'); + } catch (error) { + const message = (error as { message?: string }).message ?? 'Failed to publish to GitHub'; + if (message.includes('changed on GitHub')) dispatch(markRemoteStale(true)); + Toast.error(message); + } finally { + setPublishing(false); + } + }; + + const refreshAndRebase = async () => { + try { + const snapshot = await dispatch( + githubApi.endpoints.loadGitHubWorkspace.initiate(workspace.config, { forceRefetch: true }), + ).unwrap(); + if (snapshot.baseCommitSha === workspace.config.baseCommitSha) { + Toast('Already up to date'); + return; + } + const remoteEntries = Object.fromEntries(snapshot.entries.map((entry) => [entry.path, entry])); + const allRemotePaths = new Set([...Object.keys(workspace.baseEntries), ...Object.keys(remoteEntries)]); + const remoteChangedPaths = [...allRemotePaths].filter( + (path) => workspace.baseEntries[path]?.sha !== remoteEntries[path]?.sha, + ); + const pendingEntries = [...Object.values(workspace.working), ...Object.values(workspace.staged)]; + const pendingScopes = new Set(pendingEntries.flatMap((entry) => entry.scopePaths ?? [entry.path])); + const conflicts = remoteChangedPaths.filter((remotePath) => + [...pendingScopes].some( + (scope) => remotePath === scope || remotePath.startsWith(`${scope}/`) || scope.startsWith(`${remotePath}/`), + ), + ); + if (conflicts.length) { + dispatch(markRemoteStale(true)); + Toast.error(`Cannot rebase because these paths changed remotely: ${conflicts.join(', ')}`); + return; + } + dispatch(rebaseRemoteWorkspace(snapshot)); + Toast('Local changes rebased onto the latest branch'); + } catch (error) { + Toast.error((error as { message?: string }).message ?? 'Failed to refresh GitHub workspace'); + } + }; + + const discardAll = async () => { + if (!(await confirm({ message: 'Discard every local GitHub workspace change?' }))) return; + dispatch(discardAllChanges()); + }; + + return ( +
+
+ + + + +
+ {workspace.remoteStale && ( +
The remote branch changed. Refresh and rebase first.
+ )} +
+
+
Staged
+
+ dispatch(unstageOperations([id]))} + /> +
+
+
+
Working Space
+
+ dispatch(stageOperations([id]))} + onRevert={(id) => dispatch(discardOperations([id]))} + /> +
+
+ ); +}; diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index e2f2956..4bc8e39 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -9,6 +9,7 @@ import { ImgManagement } from '../ImgManagement/ImgManagement'; import { Icon } from '@/components/Icon/Icon'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; +import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { selectGlobalOpts } from '@/redux-feature/globalOptsSlice'; import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc, useSwitchReadonlyMode, useSwitchTheme } from '@/utils/hooks/reduxHooks'; @@ -19,6 +20,7 @@ import './Header.scss'; // eslint-disable-next-line @typescript-eslint/naming-convention export default function Header() { const { readonly, theme } = useSelector(selectGlobalOpts); + const workspaceMode = useSelector(selectWorkspaceMode); const { isDirty, type, contentIdent } = useSelector(selectCurDoc); const themeMenuRef = useRef(null); @@ -71,7 +73,13 @@ export default function Header() { return (
-
+
diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx index b48f10b..7b43d61 100644 --- a/client/src/components/Menu/Menu.tsx +++ b/client/src/components/Menu/Menu.tsx @@ -22,11 +22,12 @@ import { createRenderItem, renderDragBetweenLine, renderItemArrow } from './rend import { Shortcut } from './Shortcut'; import { TreeDataCtx, TreeRefCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type'; -import { useGetDocSubItemsQuery } from '@/redux-api/docs'; import { useGetSettingsQuery } from '@/redux-api/settings'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; +import { selectGitHubWorkspaceConfig, selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import { selectOperationMenu, updateSelectedItems } from '@/redux-feature/operationMenuSlice'; +import { useWorkspaceRootItemsQuery } from '@/utils/hooks/workspaceHooks'; import { normalizePath, scrollToView, waitAndCheck, denormalizePath } from '@/utils/utils'; import './Menu.scss'; @@ -38,13 +39,15 @@ export const Menu: FC = () => { const cm = useRef(null); const [isEnterMenu, setIsEnterMenu] = useState(false); - const { data: docRootItems = [], isFetching, isSuccess, isError, error } = useGetDocSubItemsQuery(); + const workspaceMode = useSelector(selectWorkspaceMode); + const githubWorkspaceConfig = useSelector(selectGitHubWorkspaceConfig); + const { data: docRootItems = [], isFetching, isSuccess, isError, error } = useWorkspaceRootItemsQuery(); const updateSubDocItems = useUpdateSubDocItems(); const { contentIdent: contentPath } = useSelector(selectCurDoc); const { copyCutPaths } = useSelector(selectOperationMenu); const serverStatus = useSelector(selectServerStatus); - const { data: settings } = useGetSettingsQuery(); + const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' }); const dispatch = useDispatch(); @@ -67,7 +70,7 @@ export const Menu: FC = () => { }, }; - // the reset of sub docs will be reqeusted in require(when expanding) + // the reset of sub docs will be requested in require(when expanding) return docRootItems.reduce((treeData, docRootItem) => { const { path, id, name, isFile } = docRootItem; const parentIdx = 'root'; @@ -82,7 +85,7 @@ export const Menu: FC = () => { }; return treeData; }, root); - }, [docRootItems, isFetching]); + }, [docRootItems]); const treeDataProvider = useMemo(() => new StaticTreeDataProvider(renderData), [renderData]); const selectedItemPathKeys = useMemo(() => { @@ -120,10 +123,10 @@ export const Menu: FC = () => { await tree.current?.expandSubsequently(expandKeys); } - const selectdItemPath = selectedItemPathKeys[selectedItemPathKeys.length - 1]; - tree.current?.selectItems([selectdItemPath]); + const selectedItemPath = selectedItemPathKeys[selectedItemPathKeys.length - 1]; + tree.current?.selectItems([selectedItemPath]); - const selectedItem = renderData[selectdItemPath]; + const selectedItem = renderData[selectedItemPath]; if (!selectedItem) return; // FIXME: any better way to determine when the children have been rendered? const hasChildren = await waitAndCheck(() => @@ -194,15 +197,20 @@ export const Menu: FC = () => { }; const onExpandItem = async (item: TreeItem) => { + const currentItem = renderData[item.index]; + if (!currentItem) return; + // already fetched - if (item.children?.length) return; + if (currentItem.children?.length) return; - await updateSubDocItems(item, renderData, treeDataProvider); + await updateSubDocItems(currentItem, renderData, treeDataProvider); }; let content: ReactNode = <>; if (isSuccess) { - if (!settings?.docRootPath) { + if (workspaceMode === 'github' && !githubWorkspaceConfig.owner) { + content =
Choose a GitHub workspace in Settings.
; + } else if (workspaceMode === 'local' && !settings?.docRootPath) { content = ; } else { content = ( @@ -230,10 +238,12 @@ export const Menu: FC = () => { }} canDropAt={(items, target) => { const targetItem = target.targetType === 'between-items' ? target.parentItem : target.targetItem; - const isAlreadyInTarget = items.find((item) => renderData[targetItem].children?.includes(item.index)); + const targetTreeItem = renderData[targetItem]; + if (!targetTreeItem) return false; + + const isAlreadyInTarget = items.some((item) => targetTreeItem.children?.includes(item.index)); if (isAlreadyInTarget) return false; - if (renderData[targetItem]?.isFolder) return true; - return false; + return Boolean(targetTreeItem.isFolder); }} > diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index 05487c9..d14d56a 100644 --- a/client/src/components/Menu/operations.tsx +++ b/client/src/components/Menu/operations.tsx @@ -8,17 +8,17 @@ import { useNavigate } from 'react-router-dom'; import { TreeDataCtx, TreeItemData, TreeRefCtx } from './type'; -import { - useCreateDocMutation, - useDeleteDocMutation, - useModifyDocNameMutation, - useCopyCutDocMutation, - useLazyGetDocSubItemsQuery, -} from '@/redux-api/docs'; import { selectCurDocDirty, updateCurDoc } from '@/redux-feature/curDocSlice'; import { selectOperationMenu, selectSelectedItemIds, updateCopyCut } from '@/redux-feature/operationMenuSlice'; import { useCurPath } from '@/utils/hooks/docHooks'; -import { useDeleteTab, useRenameTab } from '@/utils/hooks/reduxHooks'; +import { useDeleteTab, useRenameTab, useRenameTabs } from '@/utils/hooks/reduxHooks'; +import { + useCopyCutWorkspaceDocs, + useCreateWorkspaceDoc, + useDeleteWorkspaceDocs, + useLazyWorkspaceSubItemsQuery, + useRenameWorkspaceDoc, +} from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; import { confirm, denormalizePath, isPathsRelated, normalizePath } from '@/utils/utils'; @@ -31,10 +31,13 @@ export function deleteSubDocItem( parentItem.children = parentItem.children.filter((childIdx) => childIdx !== idx); - const isFolder = treeData[idx].isFolder; + const item = treeData[idx]; + if (!item) return true; + + const isFolder = item.isFolder; if (isFolder) { - treeData[idx].children?.forEach((childIdx) => { - deleteSubDocItem(treeData[idx], childIdx, treeData); + item.children?.forEach((childIdx) => { + deleteSubDocItem(item, childIdx, treeData); }); } @@ -47,14 +50,19 @@ export function deleteSubDocItem( } export const useUpdateSubDocItems = () => { - const [getDocSubItems] = useLazyGetDocSubItemsQuery(); + const [getDocSubItems] = useLazyWorkspaceSubItemsQuery(); return async ( parentItem: TreeItem, treeData: Record>, provider: StaticTreeDataProvider, ) => { - const { data: newSubItems, status } = await getDocSubItems({ folderDocPath: parentItem.data.path.join('/') }); + const currentParentItem = treeData[parentItem.index]; + if (!currentParentItem) return; + + const { data: newSubItems, status } = await getDocSubItems({ + folderDocPath: currentParentItem.data.path.join('/'), + }); if (status !== QueryStatus.fulfilled) { Toast.error('Failed to get sub doc items'); return; @@ -69,11 +77,11 @@ export const useUpdateSubDocItems = () => { isFolder: !isFile, children: [], canRename: true, - data: { path, id, name, parentIdx: parentItem.index }, + data: { path, id, name, parentIdx: currentParentItem.index }, }; }); - parentItem.children = newSubItems.map((d) => normalizePath(d.path)); - await provider.onDidChangeTreeDataEmitter.emit([parentItem.index]); + currentParentItem.children = newSubItems.map((d) => normalizePath(d.path)); + await provider.onDidChangeTreeDataEmitter.emit([currentParentItem.index, ...currentParentItem.children]); }; }; @@ -167,6 +175,7 @@ export const useDeleteEffect = () => { contentIdent: '', scrollTop: 0, headings: [], + syncTab: false, type: 'workspace', }), ); @@ -181,7 +190,7 @@ export const useDeleteEffect = () => { }; export const useDeleteDoc = () => { - const [deleteDocMutation] = useDeleteDocMutation(); + const deleteDocMutation = useDeleteWorkspaceDocs(); const treeDataCtx = useContext(TreeDataCtx); const selectedItemIds = useSelector(selectSelectedItemIds); @@ -217,7 +226,7 @@ export const useDeleteDoc = () => { return { filePath: deletedPath, isFile: !isFolder }; }); - await deleteDocMutation(deletePayload).unwrap(); + await deleteDocMutation(deletePayload); deleteEffect(deletePayload, true); await Promise.all( @@ -265,19 +274,19 @@ export const usePasteDoc = () => { const dispatch = useDispatch(); const { isCopy: globalIsCopy, copyCutPaths } = useSelector(selectOperationMenu); - const [getDocSubItems] = useLazyGetDocSubItemsQuery(); + const [getDocSubItems] = useLazyWorkspaceSubItemsQuery(); - const [copyCutDoc] = useCopyCutDocMutation(); + const copyCutDoc = useCopyCutWorkspaceDocs(); + const renameTabs = useRenameTabs(); const updateSubDocItems = useUpdateSubDocItems(); - const { navigate, curPath } = useCurPath(); - return async ({ pasteParentPathArr, providedTreeDataCtx, providedIsCopy, providedCopyCutPaths, + providedCopyCutItems, onCancel, }: { /** the path of the clicked item */ @@ -289,6 +298,8 @@ export const usePasteDoc = () => { providedIsCopy?: boolean; /** normalized */ providedCopyCutPaths?: string[]; + /** Stable drag snapshots; rendered tree data can be replaced while a drag is in progress. */ + providedCopyCutItems?: TreeItem[]; onCancel?: () => Promise | void; }) => { const treeCtx = providedTreeDataCtx ?? treeDataCtx; @@ -306,10 +317,20 @@ export const usePasteDoc = () => { return; } + const providedItemsByPath = new Map( + providedCopyCutItems?.map((item) => [normalizePath(item.data.path), item] as const) ?? [], + ); + const sourceNames = new Map(); const copyCutPayload = (providedCopyCutPaths ?? copyCutPaths) - .map((copyCutPath) => { - // file or dir - const copyCutDocName = treeData[copyCutPath].data.name; + .flatMap((copyCutPath) => { + // Prefer the drag snapshot. The provider may already have optimistically detached the source node. + const copyCutItem = providedItemsByPath.get(copyCutPath) ?? treeData[copyCutPath]; + if (!copyCutItem) { + Toast.warn(`${denormalizePath(copyCutPath).join('/')} is no longer available`); + return []; + } + const copyCutDocName = copyCutItem.data.name; + sourceNames.set(copyCutPath, copyCutDocName); const pasteParentPath = normalizePath(pasteParentPathArr); const pasteDoc = treeData[pasteParentPath]; @@ -319,16 +340,11 @@ export const usePasteDoc = () => { : // paster to root copyCutDocName; - return { - copyCutPath, - pastePath, - isCopy, - isFile: !treeData[copyCutPath].isFolder, - }; + return [{ copyCutPath, pastePath, isCopy, isFile: !copyCutItem.isFolder }]; }) .filter(({ pastePath, copyCutPath }) => { // check if there is a repeat name - const hasDuplicatedName = pasteParentSubDocs.some((d) => d.name === treeData[copyCutPath].data.name); + const hasDuplicatedName = pasteParentSubDocs.some((d) => d.name === sourceNames.get(copyCutPath)); if (hasDuplicatedName) { Toast.warn(`${denormalizePath(pastePath).join('/') as string} already exist in this folder!`); @@ -359,23 +375,18 @@ export const usePasteDoc = () => { return; } - await copyCutDoc(copyCutPayload).unwrap(); - - // if it is cut and current path is included in it, redirect - const curDocPayload = copyCutPayload.find(({ copyCutPath, isFile }) => - isPathsRelated(curPath, denormalizePath(copyCutPath), isFile), - ); - if (!isCopy && curDocPayload) { - // if it is a file, direct to the paste path - if (curDocPayload.isFile) { - void navigate(`/article/${curDocPayload.pastePath as string}`); - } else { - const curFile = curPath.slice( - curPath.length - (curPath.length - denormalizePath(curDocPayload.copyCutPath).length), - ); - void navigate(`/article/${normalizePath([curDocPayload.pastePath, ...curFile]) as string}`); - } - } + await copyCutDoc(copyCutPayload, () => { + if (isCopy) return; + // Run in the same synchronous turn as the GitHub overlay dispatch. React then observes the destination path + // and renamed tabs together, instead of briefly querying the tombstoned source document. + renameTabs( + copyCutPayload.map(({ copyCutPath, pastePath, isFile }) => ({ + oldPath: copyCutPath, + newPath: pastePath, + isFile, + })), + ); + }); copyCutPayload.forEach(({ copyCutPath, isCopy: isCopyInPayload }) => { if (!isCopyInPayload) { @@ -389,7 +400,7 @@ export const usePasteDoc = () => { } }); const pasteParentItem = treeData[pasteParentPathArr.length ? normalizePath(pasteParentPathArr) : 'root']; - void updateSubDocItems(pasteParentItem, treeData, provider); + if (pasteParentItem) void updateSubDocItems(pasteParentItem, treeData, provider); Toast('updated successfully!'); return true; @@ -408,7 +419,7 @@ export const usePasteDoc = () => { }; export const useDropDoc = () => { - const [getDocSubItems] = useLazyGetDocSubItemsQuery(); + const [getDocSubItems] = useLazyWorkspaceSubItemsQuery(); const pasteDoc = usePasteDoc(); @@ -425,6 +436,10 @@ export const useDropDoc = () => { }) => { const targetItemIdx = target.targetType === 'between-items' ? target.parentItem : target.targetItem; const targetItem = treeData[targetItemIdx]; + if (!targetItem) { + Toast.warn('Drop target is no longer available'); + return; + } await pasteDoc({ pasteParentPathArr: targetItem.data.path, @@ -434,6 +449,7 @@ export const useDropDoc = () => { }, providedIsCopy: false, providedCopyCutPaths: items.map((item) => normalizePath(item.data.path)), + providedCopyCutItems: items, onCancel: async () => { // reorder the moved items await Promise.all( @@ -472,7 +488,7 @@ export const CreateNewDocItem: FC = ({ item, arrow, style }) const treeDataCtx = useContext(TreeDataCtx); - const [createDoc] = useCreateDocMutation(); + const createDoc = useCreateWorkspaceDoc(); const updateSubDocItems = useUpdateSubDocItems(); const navigate = useNavigate(); @@ -500,7 +516,7 @@ export const CreateNewDocItem: FC = ({ item, arrow, style }) } try { - await createDoc({ filePath: norPath, isFile: !isFolder }).unwrap(); + await createDoc({ filePath: norPath, isFile: !isFolder }); // direct to this new doc if it is a file if (!isFolder) void navigate(`/article/${norPath as string}`); @@ -545,7 +561,7 @@ export const RenameDocItem: FC = ({ item, arrow, style }) => { const { isFolder } = item; const [newFileName, setNewFileName] = useState(item.data.name); - const [modifyName] = useModifyDocNameMutation(); + const modifyName = useRenameWorkspaceDoc(); const updateSubDocItems = useUpdateSubDocItems(); const renameTab = useRenameTab(); @@ -598,16 +614,17 @@ export const RenameDocItem: FC = ({ item, arrow, style }) => { } try { - await modifyName({ - filePath: modifyPath, - name: newFileName, - isFile: !isFolder, - }).unwrap(); + await modifyName( + { + filePath: modifyPath, + name: newFileName, + isFile: !isFolder, + }, + () => renameTab(normalizePath(path), newPath, !isFolder), + ); void updateSubDocItems(parentItem, treeData, provider); - renameTab(normalizePath(path), newPath, !isFolder); - Toast('rename successfully!'); } catch (err) { Toast.error((err as Error).message); diff --git a/client/src/components/OpenTab/OpenTab.tsx b/client/src/components/OpenTab/OpenTab.tsx index 67e966d..95bc17c 100644 --- a/client/src/components/OpenTab/OpenTab.tsx +++ b/client/src/components/OpenTab/OpenTab.tsx @@ -10,6 +10,7 @@ import type { RootState } from '@/store'; import { Icon } from '@/components/Icon/Icon'; import { useGetSettingsQuery } from '@/redux-api/settings'; import { selectCurTabs, Tab } from '@/redux-feature/curDocSlice'; +import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; import { selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import { useDeleteTab } from '@/utils/hooks/reduxHooks'; import Toast from '@/utils/Toast'; @@ -46,9 +47,12 @@ function getDisambiguations(tabs: Tab[]): Map { // eslint-disable-next-line @typescript-eslint/naming-convention export default function OpenTab() { const curTabs = useSelector(selectCurTabs); - const { data: settings } = useGetSettingsQuery(); + const githubWorkspace = useSelector(selectGithubWorkspace); + const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const draftKeys = useSelector((state: RootState) => Object.keys(state.drafts)); const serverStatus = useSelector(selectServerStatus); + const workspaceKey = + githubWorkspace.mode === 'github' ? getGitHubWorkspaceKey(githubWorkspace.config) : settings?.docRootPath; const cm = useRef(null); @@ -96,7 +100,7 @@ export default function OpenTab() { />
{curTabs.map(({ ident, active, type, title }) => { - const draftKey = getDraftKey(settings?.docRootPath, ident); + const draftKey = getDraftKey(workspaceKey, ident); const isDirty = draftKeys.includes(draftKey); const TabTitle = type === 'workspace' ? denormalizePath(ident).join('/') : title ?? ident; @@ -104,7 +108,8 @@ export default function OpenTab() { const tabName = type === 'workspace' ? denormalizePath(ident).slice(-1)[0] : title ?? ident; const subtitle = disambiguations.get(ident) ?? ''; - const notFoundTab = serverStatus === ServerStatus.CANNOT_CONNECT && type === 'workspace'; + const notFoundTab = + githubWorkspace.mode === 'local' && serverStatus === ServerStatus.CANNOT_CONNECT && type === 'workspace'; const toDoc = () => { if (notFoundTab) { diff --git a/client/src/components/Settings/GitHubSettings.tsx b/client/src/components/Settings/GitHubSettings.tsx new file mode 100644 index 0000000..21c6e8f --- /dev/null +++ b/client/src/components/Settings/GitHubSettings.tsx @@ -0,0 +1,228 @@ +import { Button } from 'primereact/button'; +import { Checkbox } from 'primereact/checkbox'; +import { Dropdown } from 'primereact/dropdown'; +import { InputText } from 'primereact/inputtext'; +import { FC, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { + useCreateGitHubRepositoryMutation, + useGetGitHubWorkspaceSettingsQuery, + useInitializeGitHubWorkspaceMutation, + useListGitHubBranchesQuery, + useListGitHubRepositoriesQuery, +} from '@/redux-api/github'; +import { updateTabs } from '@/redux-feature/curDocSlice'; +import { + emptyConfig, + selectGithubWorkspace, + setGitHubWorkspaceConfig, + syncRemoteWorkspace, +} from '@/redux-feature/githubWorkspaceSlice'; +import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; +import { useSaveDoc } from '@/utils/hooks/reduxHooks'; +import Toast from '@/utils/Toast'; + +export const GitHubSettings: FC = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const saveDoc = useSaveDoc(); + const workspace = useSelector(selectGithubWorkspace); + const { githubLogin, isGitHubLoginLoading, startGitHubLogin } = useGitHubLogin(); + const { + data: repositories = [], + error: repositoriesError, + isFetching: repositoriesLoading, + } = useListGitHubRepositoriesQuery(undefined, { skip: !githubLogin }); + const [selectedRepository, setSelectedRepository] = useState( + workspace.config.owner && workspace.config.repo ? `${workspace.config.owner}/${workspace.config.repo}` : '', + ); + const repository = repositories.find((item) => item.fullName === selectedRepository); + const { data: branches = [], isFetching: branchesLoading } = useListGitHubBranchesQuery( + repository ? { owner: repository.owner, repo: repository.name } : { owner: '', repo: '' }, + { skip: !repository }, + ); + const [branch, setBranch] = useState(workspace.config.branch); + const [docsRoot, setDocsRoot] = useState(workspace.config.docsRoot); + const { data: repositorySettings } = useGetGitHubWorkspaceSettingsQuery( + repository && branch + ? { owner: repository.owner, repo: repository.name, branch } + : { owner: '', repo: '', branch: '' }, + { skip: !repository || !branch }, + ); + const [newRepoName, setNewRepoName] = useState(''); + const [newRepoDescription, setNewRepoDescription] = useState(''); + const [newRepoPrivate, setNewRepoPrivate] = useState(false); + const [createRepository, { isLoading: creating }] = useCreateGitHubRepositoryMutation(); + const [initializeWorkspace, { isLoading: initializing }] = useInitializeGitHubWorkspaceMutation(); + + useEffect(() => { + if (repository && !branches.includes(branch)) setBranch(repository.defaultBranch); + }, [branch, branches, repository]); + + useEffect(() => { + if (repositorySettings) setDocsRoot(repositorySettings.docsRoot); + }, [repositorySettings]); + + if (isGitHubLoginLoading) { + return ( +
+ + + Loading GitHub account… + +
+ ); + } + + if (!githubLogin) { + return ( +
+ +
+ ); + } + + const selectWorkspace = async (initialize: boolean) => { + if (!repository || !branch) return; + try { + const config = { + ...emptyConfig, + owner: repository.owner, + repo: repository.name, + branch, + docsRoot: docsRoot.replace(/^\/+|\/+$/g, ''), + }; + if (initialize) { + const snapshot = await initializeWorkspace(config).unwrap(); + config.baseCommitSha = snapshot.baseCommitSha; + config.baseTreeSha = snapshot.baseTreeSha; + } + await saveDoc(); + dispatch(updateTabs([])); + void navigate('/purePage'); + dispatch(setGitHubWorkspaceConfig(config)); + Toast(initialize ? 'GitHub workspace initialized' : 'GitHub workspace selected'); + } catch (error) { + Toast.error((error as { message?: string }).message ?? 'Failed to select GitHub workspace'); + } + }; + + const createNewRepository = async () => { + if (!newRepoName.trim()) return; + try { + const result = await createRepository({ + name: newRepoName.trim(), + description: newRepoDescription.trim() || undefined, + private: newRepoPrivate, + docsRoot: 'docs', + }).unwrap(); + await saveDoc(); + dispatch(updateTabs([])); + void navigate('/purePage'); + dispatch(setGitHubWorkspaceConfig(result.config)); + dispatch(syncRemoteWorkspace(result.snapshot)); + setSelectedRepository(`${result.config.owner}/${result.config.repo}`); + Toast('GitHub repository created'); + } catch (error) { + Toast.error((error as { message?: string }).message ?? 'Failed to create GitHub repository'); + } + }; + + return ( + <> +
+ + Signed in as {githubLogin} + {repositoriesError && 'message' in repositoriesError && ( + {repositoriesError.message} + )} + + Repositories appear after this GitHub App is installed on them with read and write Contents permission. + +
+
+ + ({ label: item.fullName, value: item.fullName }))} + loading={repositoriesLoading} + filter + placeholder="Select a writable repository" + onChange={(event) => { + setSelectedRepository(event.value as string); + const selected = repositories.find((item) => item.fullName === event.value); + setBranch(selected?.defaultBranch ?? ''); + }} + /> + ({ label: item, value: item }))} + loading={branchesLoading} + placeholder="Select branch" + onChange={(event) => { + setBranch(event.value as string); + }} + /> + { + setDocsRoot(event.target.value); + }} + /> +
+
+
+
+ + { + setNewRepoName(event.target.value); + }} + /> + { + setNewRepoDescription(event.target.value); + }} + /> + +
+ + ); +}; diff --git a/client/src/components/Settings/Settings.scss b/client/src/components/Settings/Settings.scss index 74b9219..9c0d261 100644 --- a/client/src/components/Settings/Settings.scss +++ b/client/src/components/Settings/Settings.scss @@ -2,11 +2,29 @@ .settings-container { width: 600px; - height: 500px; + height: fit-content; .setting-item { padding-top: 20px; margin-bottom: 20px; border-top: 1px solid $shadowColor; + display: flex; + flex-direction: column; + gap: 10px; + } + .p-dropdown, + .p-inputtext { + width: 100%; + } + .workspace-setting { + display: flex; + gap: 10px; + } + .github-access-message { + color: var(--shallowTextColor, currentColor); + line-height: 1.4; + } + .github-access-error { + color: var(--red-500, #dc2626); } .setting-label { display: block; @@ -30,7 +48,8 @@ } .git-service-status { margin-top: 10px; - .no-git-service, .git-service-status-active { + .no-git-service, + .git-service-status-active { @include flex-center(); i { margin-right: 5px; diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 966d458..50e7b8d 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -1,11 +1,20 @@ import { Button } from 'primereact/button'; import { Chips } from 'primereact/chips'; import { InputText } from 'primereact/inputtext'; +import { SelectButton } from 'primereact/selectbutton'; import { FC, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { GitHubSettings } from './GitHubSettings'; import { FolderSelectorModal } from '@/components/FolderSelector/FolderSelector'; import { useGetGitStatusQuery } from '@/redux-api/git'; import { Settings } from '@/redux-api/settings'; +import { selectCurDocDirty, updateTabs } from '@/redux-feature/curDocSlice'; +import { selectGithubWorkspace, setWorkspaceMode, type WorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; +import { useSaveDoc } from '@/utils/hooks/reduxHooks'; +import { confirm } from '@/utils/utils'; import './Settings.scss'; @@ -14,13 +23,23 @@ export interface SettingsBoxProps { onUpdateSettings?: (settings: Settings) => void; } +const workspaceModeOptions = [ + { label: 'Local', value: 'local' }, + { label: 'GitHub', value: 'github' }, +]; + export const SettingsBox: FC = ({ settings, onUpdateSettings }) => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const saveDoc = useSaveDoc(); + const isDirty = useSelector(selectCurDocDirty); + const workspaceMode = useSelector(selectGithubWorkspace).mode; const [workspace, setWorkspace] = useState(''); const [ignoreDirs, setIgnoreDirs] = useState([]); const [showFolderSelector, setShowFolderSelector] = useState(false); const { data: { noGit: noGitSetup, remotes } = { noGit: true, remotes: [] }, isLoading: isLoadingGitStatus } = - useGetGitStatusQuery(); + useGetGitStatusQuery(undefined, { skip: workspaceMode === 'github' }); let gitInfoContent: React.ReactNode | null = null; if (isLoadingGitStatus) { @@ -73,48 +92,74 @@ export const SettingsBox: FC = ({ settings, onUpdateSettings } updateSettings({ docRootPath: selectedFolderPath }); }; + const switchWorkspaceMode = async (mode: WorkspaceMode) => { + if (mode === workspaceMode) return; + if (isDirty) { + const shouldSave = await confirm({ message: 'Save the current document before switching workspace modes?' }); + if (!shouldSave) return; + await saveDoc(); + } + dispatch(updateTabs([])); + void navigate('/purePage'); + dispatch(setWorkspaceMode(mode)); + }; + return (
- -
- { - setWorkspace(e.target.value); - updateSettings({ docRootPath: e.target.value }); - }} - /> - -
- -
{gitInfoContent}
-
-
- - { - const newIgnoreDirs = e.value ?? []; - setIgnoreDirs(newIgnoreDirs); - updateSettings({ ignoreDirs: newIgnoreDirs }); - }} + + void switchWorkspaceMode(event.value as WorkspaceMode)} />
+ {workspaceMode === 'github' ? ( + + ) : ( + <> +
+ +
+ { + setWorkspace(e.target.value); + updateSettings({ docRootPath: e.target.value }); + }} + /> + +
+ +
{gitInfoContent}
+
+
+ + { + const newIgnoreDirs = e.value ?? []; + setIgnoreDirs(newIgnoreDirs); + updateSettings({ ignoreDirs: newIgnoreDirs }); + }} + /> +
+ + )}
); }; diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index f1af0fa..9d39a3e 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -10,11 +10,13 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { GitBox } from '@/components/GitBox/GitBox'; +import { GitHubChangesBox } from '@/components/GitBox/GitHubChangesBox'; import { Icon } from '@/components/Icon/Icon'; import { SettingsBox } from '@/components/Settings/Settings'; import { Settings, useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings'; import { updateTabs } from '@/redux-feature/curDocSlice'; import { clearAllDrafts } from '@/redux-feature/draftsSlice'; +import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { updateGlobalOpts, selectGlobalOpts, selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import ErrorBoundary from '@/utils/ErrorBoundary/ErrorBoundary'; import Toast from '@/utils/Toast'; @@ -27,7 +29,8 @@ export const Sidebar: FC = () => { const [settingsLoading, setSettingsLoading] = useState(false); const [newSettings, setNewSettings] = useState({}); - const { data: settings } = useGetSettingsQuery(); + const workspaceMode = useSelector(selectWorkspaceMode); + const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' }); const [updateSettings] = useUpdateSettingsMutation(); const { menuCollapse } = useSelector(selectGlobalOpts); @@ -52,6 +55,10 @@ export const Sidebar: FC = () => { }; const handleConfirmSettings = async () => { + if (workspaceMode === 'github') { + setSettingsShow(false); + return; + } try { setSettingsLoading(true); @@ -91,7 +98,7 @@ export const Sidebar: FC = () => { icon={menuCollapse ? MenuOpen : MenuClose} toolTipContent={menuCollapse ? 'show menu' : 'hide menu'} toolTipPosition="right" - disabled={isServerNotConnected} + disabled={workspaceMode === 'local' && isServerNotConnected} onClick={() => { dispatch( updateGlobalOpts({ @@ -107,13 +114,11 @@ export const Sidebar: FC = () => { icon={GitFlow} toolTipContent="Git Flow" toolTipPosition="right" - disabled={isServerNotConnected} + disabled={workspaceMode === 'local' && isServerNotConnected} onClick={onClickGit} /> - - - + {workspaceMode === 'github' ? : } { icon={SettingIcon} toolTipContent="Settings" toolTipPosition="right" - disabled={isServerNotConnected} onClick={() => { setSettingsShow(true); }} @@ -130,12 +134,12 @@ export const Sidebar: FC = () => { header={
⚙️ Settings
} footer={
- + /> )} - + /> + {change.status[0]}
))} @@ -87,22 +71,44 @@ const ChangeList: FC<{ ); export const GitHubChangesBox: FC = () => { - const dispatch = useDispatch(); - const workspace = useSelector(selectGithubWorkspace); + const config = useSelector(selectGitHubWorkspaceConfig); + const workspace = useGitHubWorkspaceSnapshot(); + const renameTabs = useRenameTabs(); + const deleteTabs = useDeleteTab(); const [publishing, setPublishing] = useState(false); const [publish] = usePublishGitHubWorkspaceMutation(); - const stagedOperationIds = useMemo( - () => new Set(Object.values(workspace.staged).map((entry) => entry.operationId)), - [workspace.staged], - ); - const workingGroups = useMemo( - () => groupChanges(Object.values(workspace.working).filter((entry) => !stagedOperationIds.has(entry.operationId))), - [stagedOperationIds, workspace.working], - ); - const stagedGroups = useMemo(() => groupChanges(Object.values(workspace.staged)), [workspace.staged]); + const workingChanges = visibleChanges(workspace.workingChanges); + const stagedChanges = visibleChanges(workspace.stagedChanges); + + const applyPathMappings = (result: MutationResult) => { + const operations = result.pathMappings + .filter((mapping) => mapping.kind === 'file' && mapping.oldPath.endsWith('.md')) + .map((mapping) => ({ + oldPath: getVisibleLogicalPath(config, mapping.oldPath), + newPath: getVisibleLogicalPath(config, mapping.newPath), + isFile: true, + })); + if (operations.length) renameTabs(operations); + }; + + const closeDiscardedUntrackedTabs = async (changes: WorkspaceChange[]) => { + const deletedIds = new Set( + changes.filter((change) => change.status === 'DELETED').map((change) => change.oldEntry?.id), + ); + const paths = changes + .filter( + (change) => + (change.status === 'UNTRACKED' || change.status === 'ADDED') && + change.kind === 'file' && + change.path.endsWith('.md') && + !deletedIds.has(change.newEntry?.id), + ) + .map((change) => getVisibleLogicalPath(config, change.path)); + if (paths.length) await deleteTabs(paths, { force: true }); + }; const publishChanges = async () => { - if (!stagedGroups.length || workspace.remoteStale) return; + if (!stagedChanges.length || workspace.remoteStale) return; let title = ''; let body = ''; const accepted = await confirm({ @@ -119,19 +125,27 @@ export const GitHubChangesBox: FC = () => { Toast.warn('Commit title cannot be blank'); return; } + let token = ''; try { setPublishing(true); - const snapshot = await publish({ - config: workspace.config, - staged: Object.values(workspace.staged), - title: title.trim(), - body, - }).unwrap(); - dispatch(applyPublishedWorkspace(snapshot)); + const plan = await githubWorkspaceStore.beginPublish(); + token = plan.token; + const snapshot = await publish({ config, plan, title: title.trim(), body }).unwrap(); + await githubWorkspaceStore.completePublish(snapshot, token); Toast('Published to GitHub'); } catch (error) { + if (token) githubWorkspaceStore.abortPublish(token); const message = (error as { message?: string }).message ?? 'Failed to publish to GitHub'; - if (message.includes('changed on GitHub')) dispatch(markRemoteStale(true)); + if (message.includes('changed on GitHub')) { + try { + const remote = await store + .dispatch(githubApi.endpoints.loadGitHubWorkspace.initiate(config, { forceRefetch: true })) + .unwrap(); + await githubWorkspaceStore.attachRemote(remote); + } catch { + // Keep the original publish error; another refresh can retry the stale check. + } + } Toast.error(message); } finally { setPublishing(false); @@ -140,31 +154,18 @@ export const GitHubChangesBox: FC = () => { const refreshAndRebase = async () => { try { - const snapshot = await dispatch( - githubApi.endpoints.loadGitHubWorkspace.initiate(workspace.config, { forceRefetch: true }), - ).unwrap(); - if (snapshot.baseCommitSha === workspace.config.baseCommitSha) { + const remote = await store + .dispatch(githubApi.endpoints.loadGitHubWorkspace.initiate(config, { forceRefetch: true })) + .unwrap(); + if (remote.baseCommitSha === workspace.baseCommitSha) { Toast('Already up to date'); return; } - const remoteEntries = Object.fromEntries(snapshot.entries.map((entry) => [entry.path, entry])); - const allRemotePaths = new Set([...Object.keys(workspace.baseEntries), ...Object.keys(remoteEntries)]); - const remoteChangedPaths = [...allRemotePaths].filter( - (path) => workspace.baseEntries[path]?.sha !== remoteEntries[path]?.sha, - ); - const pendingEntries = [...Object.values(workspace.working), ...Object.values(workspace.staged)]; - const pendingScopes = new Set(pendingEntries.flatMap((entry) => entry.scopePaths ?? [entry.path])); - const conflicts = remoteChangedPaths.filter((remotePath) => - [...pendingScopes].some( - (scope) => remotePath === scope || remotePath.startsWith(`${scope}/`) || scope.startsWith(`${remotePath}/`), - ), - ); - if (conflicts.length) { - dispatch(markRemoteStale(true)); - Toast.error(`Cannot rebase because these paths changed remotely: ${conflicts.join(', ')}`); + const result = await githubWorkspaceStore.rebase(remote); + if (result.conflicts.length) { + Toast.error(`Cannot rebase because these paths changed remotely: ${result.conflicts.join(', ')}`); return; } - dispatch(rebaseRemoteWorkspace(snapshot)); Toast('Local changes rebased onto the latest branch'); } catch (error) { Toast.error((error as { message?: string }).message ?? 'Failed to refresh GitHub workspace'); @@ -173,7 +174,10 @@ export const GitHubChangesBox: FC = () => { const discardAll = async () => { if (!(await confirm({ message: 'Discard every local GitHub workspace change?' }))) return; - dispatch(discardAllChanges()); + const discardedChanges = [...workspace.workingChanges, ...workspace.stagedChanges]; + const result = await githubWorkspaceStore.discardAll(); + applyPathMappings(result); + await closeDiscardedUntrackedTabs(discardedChanges); }; return ( @@ -185,15 +189,15 @@ export const GitHubChangesBox: FC = () => {
diff --git a/client/src/components/Settings/GitHubSettings.tsx b/client/src/components/Settings/GitHubSettings.tsx index 21c6e8f..1de8c5c 100644 --- a/client/src/components/Settings/GitHubSettings.tsx +++ b/client/src/components/Settings/GitHubSettings.tsx @@ -14,12 +14,7 @@ import { useListGitHubRepositoriesQuery, } from '@/redux-api/github'; import { updateTabs } from '@/redux-feature/curDocSlice'; -import { - emptyConfig, - selectGithubWorkspace, - setGitHubWorkspaceConfig, - syncRemoteWorkspace, -} from '@/redux-feature/githubWorkspaceSlice'; +import { emptyConfig, selectGithubWorkspace, setGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc } from '@/utils/hooks/reduxHooks'; import Toast from '@/utils/Toast'; @@ -96,9 +91,7 @@ export const GitHubSettings: FC = () => { docsRoot: docsRoot.replace(/^\/+|\/+$/g, ''), }; if (initialize) { - const snapshot = await initializeWorkspace(config).unwrap(); - config.baseCommitSha = snapshot.baseCommitSha; - config.baseTreeSha = snapshot.baseTreeSha; + await initializeWorkspace(config).unwrap(); } await saveDoc(); dispatch(updateTabs([])); @@ -123,7 +116,6 @@ export const GitHubSettings: FC = () => { dispatch(updateTabs([])); void navigate('/purePage'); dispatch(setGitHubWorkspaceConfig(result.config)); - dispatch(syncRemoteWorkspace(result.snapshot)); setSelectedRepository(`${result.config.owner}/${result.config.repo}`); Toast('GitHub repository created'); } catch (error) { diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 50e7b8d..1f4f763 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -111,7 +111,11 @@ export const SettingsBox: FC = ({ settings, onUpdateSettings } void switchWorkspaceMode(event.value as WorkspaceMode)} + allowEmpty={false} + onChange={(event) => { + if (event.value == null) return; + void switchWorkspaceMode(event.value as WorkspaceMode); + }} />
{workspaceMode === 'github' ? ( diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts index 6fc9e19..c306036 100644 --- a/client/src/redux-api/github.ts +++ b/client/src/redux-api/github.ts @@ -5,7 +5,12 @@ import { Octokit } from '@octokit/rest'; import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; -import type { GitHubOverlayEntry, GitHubTreeEntry, GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import type { + GitMode, + PublishPlan, + RemoteWorkspaceSnapshot, + WorkspaceDescriptor, +} from '@markdown-editor/github-workspace'; import { getGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; @@ -17,11 +22,7 @@ export interface GitHubRepositoryOption { private: boolean; } -export interface GitHubWorkspaceSnapshot { - baseCommitSha: string; - baseTreeSha: string; - entries: GitHubTreeEntry[]; -} +export type GitHubWorkspaceSnapshot = RemoteWorkspaceSnapshot; export interface CreateGitHubRepositoryPayload { name: string; @@ -31,8 +32,8 @@ export interface CreateGitHubRepositoryPayload { } export interface PublishGitHubWorkspacePayload { - config: GitHubWorkspaceConfig; - staged: GitHubOverlayEntry[]; + config: WorkspaceDescriptor; + plan: PublishPlan; title: string; body: string; } @@ -56,7 +57,7 @@ const getErrorMessage = (error: unknown) => { }; const getSnapshot = async ( - config: Pick, + config: Pick, ): Promise => { const octokit = getOctokit(); const refResponse = await octokit.request('GET /repos/{owner}/{repo}/git/ref/{ref}', { @@ -85,8 +86,8 @@ const getSnapshot = async ( .filter((entry) => entry.path && entry.mode && entry.type && entry.sha && entry.type !== 'tree') .map((entry) => ({ path: entry.path, - mode: entry.mode as GitHubTreeEntry['mode'], - type: entry.type as GitHubTreeEntry['type'], + mode: entry.mode as GitMode, + type: entry.type as RemoteWorkspaceSnapshot['entries'][number]['type'], sha: entry.sha, size: entry.size, })); @@ -94,7 +95,7 @@ const getSnapshot = async ( }; const initializeWorkspace = async ( - config: Pick, + config: Pick, ): Promise => { const octokit = getOctokit(); const docsRoot = config.docsRoot.replace(/^\/+|\/+$/g, ''); @@ -241,7 +242,7 @@ export const githubApi = createApi({ } }, }), - loadGitHubWorkspace: builder.query({ + loadGitHubWorkspace: builder.query({ queryFn: async (config) => { try { return { data: await getSnapshot(config) }; @@ -268,7 +269,7 @@ export const githubApi = createApi({ }, }), createGitHubRepository: builder.mutation< - { config: GitHubWorkspaceConfig; snapshot: GitHubWorkspaceSnapshot }, + { config: WorkspaceDescriptor; snapshot: GitHubWorkspaceSnapshot }, CreateGitHubRepositoryPayload >({ queryFn: async (payload) => { @@ -288,7 +289,7 @@ export const githubApi = createApi({ const snapshot = await initializeWorkspace(partialConfig); return { data: { - config: { ...partialConfig, baseCommitSha: snapshot.baseCommitSha, baseTreeSha: snapshot.baseTreeSha }, + config: partialConfig, snapshot, }, }; @@ -298,7 +299,7 @@ export const githubApi = createApi({ }, invalidatesTags: ['Repositories'], }), - initializeGitHubWorkspace: builder.mutation({ + initializeGitHubWorkspace: builder.mutation({ queryFn: async (config) => { try { return { data: await initializeWorkspace(config) }; @@ -309,19 +310,19 @@ export const githubApi = createApi({ invalidatesTags: ['Workspace'], }), publishGitHubWorkspace: builder.mutation({ - queryFn: async ({ config, staged, title, body }) => { + queryFn: async ({ config, plan, title, body }) => { try { const octokit = getOctokit(); const current = await getSnapshot(config); - if (current.baseCommitSha !== config.baseCommitSha) { + if (current.baseCommitSha !== plan.baseCommitSha) { throw new Error( 'The selected branch changed on GitHub. Rebase or discard local changes before publishing.', ); } const treeEntries = await Promise.all( - staged.map(async (entry) => { - if (entry.action === 'delete') { + plan.entries.map(async (entry) => { + if (entry.sha === null && entry.content === undefined) { return { path: entry.path, mode: entry.mode, type: entry.type, sha: null }; } let sha = entry.sha; @@ -341,7 +342,7 @@ export const githubApi = createApi({ const tree = await octokit.request('POST /repos/{owner}/{repo}/git/trees', { owner: config.owner, repo: config.repo, - base_tree: config.baseTreeSha, + base_tree: plan.baseTreeSha, tree: treeEntries, }); const commit = await octokit.request('POST /repos/{owner}/{repo}/git/commits', { @@ -349,7 +350,7 @@ export const githubApi = createApi({ repo: config.repo, message: body.trim() ? `${title}\n\n${body}` : title, tree: tree.data.sha, - parents: [config.baseCommitSha], + parents: [plan.baseCommitSha], }); await octokit.request('PATCH /repos/{owner}/{repo}/git/refs/{ref}', { owner: config.owner, diff --git a/client/src/redux-feature/githubWorkspaceSlice.ts b/client/src/redux-feature/githubWorkspaceSlice.ts index bae7f4d..cfc1186 100644 --- a/client/src/redux-feature/githubWorkspaceSlice.ts +++ b/client/src/redux-feature/githubWorkspaceSlice.ts @@ -1,80 +1,14 @@ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/store'; +import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; export type WorkspaceMode = 'github' | 'local'; +export type GitHubWorkspaceConfig = WorkspaceDescriptor; -export interface GitHubWorkspaceConfig { - owner: string; - repo: string; - branch: string; - docsRoot: string; - /** Commit that the local workspace was read from and must still be the branch head when publishing. */ - baseCommitSha: string; - /** Root tree for baseCommitSha; staged changes are applied against this tree to create one new Git tree. */ - baseTreeSha: string; -} - -/** Metadata for one tracked path in the remote base tree. File contents are fetched lazily by blob SHA. */ -export interface GitHubTreeEntry { - path: string; - mode: '040000' | '100644' | '100755' | '120000' | '160000'; - type: 'blob' | 'commit' | 'tree'; - sha: string; - size?: number; -} - -export interface GitHubOverlayEntry { - /** Repository-relative path, including docsRoot and the .md extension for documents. */ - path: string; - /** `upsert` adds/replaces this path; `delete` removes it from the effective or published tree. */ - action: 'delete' | 'upsert'; - mode: GitHubTreeEntry['mode']; - type: 'blob' | 'commit'; - sha?: string; - /** Present for new/edited text; absent when an existing remote blob SHA can be reused. */ - content?: string; - /** All entries in one logical operation share an ID so directory operations are staged atomically. */ - operationId: string; - /** Human-readable summary shown by the Working/Staged lists; it is metadata and does not drive publishing. */ - label: string; - /** Source/destination roots used only to detect overlap when rebasing onto a newer remote commit. */ - scopePaths?: string[]; -} - -/** - * The IndexedDB record for one owner/repo/branch/docsRoot workspace. - * - * Tree model: - * - `baseEntries` is the immutable manifest at config.baseCommitSha. - * - `working` is a path-keyed overlay used by the menu and editor. An upsert replaces/adds a base path and a delete - * hides it, so the visible tree is `baseEntries + working`. - * - `staged` is a snapshot copied from working. Staging does not remove the working entry, which means a later edit - * can remain in working while the older staged snapshot is published. - * - Publishing applies only staged entries to config.baseTreeSha, creates one commit, advances the branch once, and - * replaces the base manifest. A working entry is cleared only when it still matches the published staged snapshot. - * - * A store listener writes this object to IndexedDB after tree/overlay mutations. The active mode and workspace config - * are also kept in localStorage so startup can select a workspace before its full IndexedDB record is hydrated. - * - * Important: this is a net tree overlay, not an executable queue of GitHub API calls. Publishing sends the staged - * paths together in one Git tree. `operationId` only groups related path changes for the UI/staging boundary; GitHub - * never receives the create/update/move labels or the order in which the user performed those actions. - */ -export interface PersistedGitHubWorkspace { - config: GitHubWorkspaceConfig; - baseEntries: Record; - working: Record; - staged: Record; - remoteStale: boolean; -} - -interface GitHubWorkspaceState extends PersistedGitHubWorkspace { - /** UI/backend selection; persisted separately in localStorage, not in each IndexedDB workspace record. */ +interface GitHubWorkspaceState { mode: WorkspaceMode; - /** Workspace key whose IndexedDB record has finished loading; empty while hydration is pending. */ - hydratedKey: string; + config: GitHubWorkspaceConfig; } export const emptyConfig: GitHubWorkspaceConfig = { @@ -82,8 +16,6 @@ export const emptyConfig: GitHubWorkspaceConfig = { repo: '', branch: '', docsRoot: 'docs', - baseCommitSha: '', - baseTreeSha: '', }; const MODE_STORAGE_KEY = 'workspace-mode'; @@ -94,23 +26,25 @@ const readMode = (): WorkspaceMode => (window.localStorage.getItem(MODE_STORAGE_ const readConfig = (): GitHubWorkspaceConfig => { try { const value = window.localStorage.getItem(CONFIG_STORAGE_KEY); - return value ? (JSON.parse(value) as GitHubWorkspaceConfig) : emptyConfig; + if (!value) return emptyConfig; + const parsed = JSON.parse(value) as Partial; + return { + owner: parsed.owner ?? '', + repo: parsed.repo ?? '', + branch: parsed.branch ?? '', + docsRoot: parsed.docsRoot ?? 'docs', + }; } catch { return emptyConfig; } }; -export const getGitHubWorkspaceKey = (config: Pick) => +export const getGitHubWorkspaceKey = (config: GitHubWorkspaceConfig) => [config.owner, config.repo, config.branch, config.docsRoot].join('/'); const initialState: GitHubWorkspaceState = { mode: readMode(), config: readConfig(), - baseEntries: {}, - working: {}, - staged: {}, - hydratedKey: '', - remoteStale: false, }; const githubWorkspaceSlice = createSlice({ @@ -122,155 +56,13 @@ const githubWorkspaceSlice = createSlice({ window.localStorage.setItem(MODE_STORAGE_KEY, action.payload); }, setGitHubWorkspaceConfig: (state, action: PayloadAction) => { - // Point at the new workspace immediately, then let useGitHubWorkspaceSync hydrate its keyed IndexedDB record. state.config = action.payload; - state.hydratedKey = ''; - state.baseEntries = {}; - state.working = {}; - state.staged = {}; - state.remoteStale = false; window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(action.payload)); }, - hydrateGitHubWorkspace: (state, action: PayloadAction) => { - const key = getGitHubWorkspaceKey(state.config); - if (action.payload && getGitHubWorkspaceKey(action.payload.config) === key) { - state.config = action.payload.config; - state.baseEntries = action.payload.baseEntries; - state.working = action.payload.working; - state.staged = action.payload.staged; - state.remoteStale = action.payload.remoteStale; - } else { - // The commit SHA stored in localStorage is only a workspace hint. Do not - // treat it as a loaded manifest when IndexedDB has no matching snapshot. - state.config.baseCommitSha = ''; - state.config.baseTreeSha = ''; - state.baseEntries = {}; - state.working = {}; - state.staged = {}; - state.remoteStale = false; - } - state.hydratedKey = key; - }, - syncRemoteWorkspace: ( - state, - action: PayloadAction<{ baseCommitSha: string; baseTreeSha: string; entries: GitHubTreeEntry[] }>, - ) => { - const hasPendingChanges = Object.keys(state.working).length > 0 || Object.keys(state.staged).length > 0; - if ( - hasPendingChanges && - state.config.baseCommitSha && - state.config.baseCommitSha !== action.payload.baseCommitSha - ) { - state.remoteStale = true; - return; - } - - state.config.baseCommitSha = action.payload.baseCommitSha; - state.config.baseTreeSha = action.payload.baseTreeSha; - state.baseEntries = Object.fromEntries(action.payload.entries.map((entry) => [entry.path, entry])); - state.remoteStale = false; - window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(state.config)); - }, - applyWorkingEntries: (state, action: PayloadAction) => { - // The overlay is keyed by repository path, so the newest mutation for a path becomes its desired state. A - // delete of a path created only in this workspace cancels that creation completely instead of retaining a - // meaningless create-then-delete pair. Callers preserve an existing operationId when a save refines an - // unstaged create/update/move; staged snapshots must not be rewritten. - action.payload.forEach((entry) => { - if ( - entry.action === 'delete' && - !state.baseEntries[entry.path] && - state.working[entry.path]?.action === 'upsert' && - !state.staged[entry.path] - ) { - delete state.working[entry.path]; - return; - } - state.working[entry.path] = entry; - }); - }, - stageOperations: (state, action: PayloadAction) => { - // Copy complete operation groups. Keep working intact: it is still the effective tree seen by the editor. If a - // later edit touches a staged path, its hook assigns a new operationId so the staged snapshot stays immutable. - const operationIds = new Set(action.payload); - Object.values(state.working).forEach((entry) => { - if (operationIds.has(entry.operationId)) state.staged[entry.path] = { ...entry }; - }); - }, - stageAllOperations: (state) => { - state.staged = Object.fromEntries(Object.entries(state.working).map(([path, entry]) => [path, { ...entry }])); - }, - unstageOperations: (state, action: PayloadAction) => { - // Unstaging removes only the snapshot; the corresponding working overlay remains visible and editable. - const operationIds = new Set(action.payload); - Object.entries(state.staged).forEach(([path, entry]) => { - if (operationIds.has(entry.operationId)) delete state.staged[path]; - }); - }, - discardOperations: (state, action: PayloadAction) => { - // Discard currently removes the selected net overlay group from both views. It does not replay a chronological - // command log; dependent-operation handling therefore belongs in the future normalized-diff model. - const operationIds = new Set(action.payload); - Object.entries(state.working).forEach(([path, entry]) => { - if (operationIds.has(entry.operationId)) delete state.working[path]; - }); - Object.entries(state.staged).forEach(([path, entry]) => { - if (operationIds.has(entry.operationId)) delete state.staged[path]; - }); - }, - discardAllChanges: (state) => { - state.working = {}; - state.staged = {}; - state.remoteStale = false; - }, - applyPublishedWorkspace: ( - state, - action: PayloadAction<{ baseCommitSha: string; baseTreeSha: string; entries: GitHubTreeEntry[] }>, - ) => { - // Preserve edits made after staging by clearing only working entries identical to what was just published. - Object.entries(state.staged).forEach(([path, stagedEntry]) => { - const workingEntry = state.working[path]; - if (workingEntry && JSON.stringify(workingEntry) === JSON.stringify(stagedEntry)) delete state.working[path]; - }); - state.staged = {}; - state.config.baseCommitSha = action.payload.baseCommitSha; - state.config.baseTreeSha = action.payload.baseTreeSha; - state.baseEntries = Object.fromEntries(action.payload.entries.map((entry) => [entry.path, entry])); - state.remoteStale = false; - window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(state.config)); - }, - rebaseRemoteWorkspace: ( - state, - action: PayloadAction<{ baseCommitSha: string; baseTreeSha: string; entries: GitHubTreeEntry[] }>, - ) => { - state.config.baseCommitSha = action.payload.baseCommitSha; - state.config.baseTreeSha = action.payload.baseTreeSha; - state.baseEntries = Object.fromEntries(action.payload.entries.map((entry) => [entry.path, entry])); - state.remoteStale = false; - window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(state.config)); - }, - markRemoteStale: (state, action: PayloadAction) => { - state.remoteStale = action.payload; - }, }, }); -export const { - applyPublishedWorkspace, - applyWorkingEntries, - discardAllChanges, - discardOperations, - hydrateGitHubWorkspace, - markRemoteStale, - rebaseRemoteWorkspace, - setGitHubWorkspaceConfig, - setWorkspaceMode, - stageAllOperations, - stageOperations, - syncRemoteWorkspace, - unstageOperations, -} = githubWorkspaceSlice.actions; - +export const { setGitHubWorkspaceConfig, setWorkspaceMode } = githubWorkspaceSlice.actions; export const selectGithubWorkspace = (state: RootState) => state.githubWorkspace; export const selectWorkspaceMode = (state: RootState) => state.githubWorkspace.mode; export const selectGitHubWorkspaceConfig = (state: RootState) => state.githubWorkspace.config; diff --git a/client/src/store.ts b/client/src/store.ts index 81fb939..c5f2288 100644 --- a/client/src/store.ts +++ b/client/src/store.ts @@ -1,27 +1,12 @@ -import { configureStore, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import { configureStore } from '@reduxjs/toolkit'; import { docsApi } from './redux-api/docs'; import { githubApi } from './redux-api/github'; import curDocReducer from './redux-feature/curDocSlice'; import draftsReducer from './redux-feature/draftsSlice'; -import githubWorkspaceReducer, { - applyPublishedWorkspace, - applyWorkingEntries, - discardAllChanges, - discardOperations, - getGitHubWorkspaceKey, - markRemoteStale, - rebaseRemoteWorkspace, - stageAllOperations, - stageOperations, - syncRemoteWorkspace, - unstageOperations, -} from './redux-feature/githubWorkspaceSlice'; +import githubWorkspaceReducer from './redux-feature/githubWorkspaceSlice'; import globalOptsReducer from './redux-feature/globalOptsSlice'; import operationMenuReducer from './redux-feature/operationMenuSlice'; -import { writeGitHubWorkspace } from './utils/githubWorkspaceDb'; - -const githubWorkspaceListener = createListenerMiddleware(); export const store = configureStore({ reducer: { @@ -36,35 +21,7 @@ export const store = configureStore({ // This middleware must be added as well - it manages cache lifetimes and expiration // We need to keep all of the existing standard middleware like redux-thunk in the store setup, // which leads to the use of concat - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(githubWorkspaceListener.middleware, docsApi.middleware, githubApi.middleware), -}); - -githubWorkspaceListener.startListening({ - matcher: isAnyOf( - applyPublishedWorkspace, - applyWorkingEntries, - discardAllChanges, - discardOperations, - markRemoteStale, - rebaseRemoteWorkspace, - stageAllOperations, - stageOperations, - syncRemoteWorkspace, - unstageOperations, - ), - effect: async (_, listenerApi) => { - const workspace = (listenerApi.getState() as RootState).githubWorkspace; - const key = getGitHubWorkspaceKey(workspace.config); - if (!workspace.config.owner || !workspace.config.repo || !workspace.config.branch) return; - await writeGitHubWorkspace(key, { - config: workspace.config, - baseEntries: workspace.baseEntries, - working: workspace.working, - staged: workspace.staged, - remoteStale: workspace.remoteStale, - }); - }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(docsApi.middleware, githubApi.middleware), }); // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/client/src/utils/githubWorkspace.ts b/client/src/utils/githubWorkspace.ts index 6e7b8c3..890de09 100644 --- a/client/src/utils/githubWorkspace.ts +++ b/client/src/utils/githubWorkspace.ts @@ -1,91 +1,17 @@ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ /* eslint-disable @typescript-eslint/no-magic-numbers */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ /* eslint-disable no-control-regex */ -import type { DocTreeNode } from '@/redux-api/docsApiType'; -import type { GitHubOverlayEntry, GitHubTreeEntry, GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; import { denormalizePath, normalizePath } from '@/utils/utils'; -export interface GitHubWorkspaceLike { - config: GitHubWorkspaceConfig; - baseEntries: Record; - working: Record; -} - -export type EffectiveGitHubEntry = GitHubTreeEntry & { content?: string; operationId?: string; label?: string }; - const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); -export const getRepositoryDocPath = (config: GitHubWorkspaceConfig, logicalPath: string, isFile: boolean) => { +export const getRepositoryDocPath = (config: WorkspaceDescriptor, logicalPath: string, isFile: boolean) => { const relative = denormalizePath(logicalPath).filter(Boolean).join('/'); const path = [cleanPath(config.docsRoot), relative].filter(Boolean).join('/'); return isFile ? `${path}.md` : path; }; -export const getEffectiveGitHubEntries = (workspace: GitHubWorkspaceLike): Record => { - const entries: Record = { ...workspace.baseEntries }; - Object.values(workspace.working).forEach((overlay) => { - if (overlay.action === 'delete') { - delete entries[overlay.path]; - return; - } - entries[overlay.path] = { - path: overlay.path, - mode: overlay.mode, - type: overlay.type, - sha: overlay.sha ?? '', - content: overlay.content, - operationId: overlay.operationId, - label: overlay.label, - }; - }); - return entries; -}; - -const isHiddenMenuPath = (parts: string[]) => - parts.some((part) => (part.startsWith('.') && part !== '.gitkeep') || part === '_assets'); - -export const getGitHubSubItems = (workspace: GitHubWorkspaceLike, encodedFolderPath = ''): DocTreeNode[] => { - const root = cleanPath(workspace.config.docsRoot); - const folderParts = encodedFolderPath ? denormalizePath(encodedFolderPath).filter(Boolean) : []; - const folderPath = folderParts.join('/'); - const childDirectories = new Set(); - const childFiles = new Set(); - - Object.keys(getEffectiveGitHubEntries(workspace)).forEach((repositoryPath) => { - if (root && repositoryPath !== root && !repositoryPath.startsWith(`${root}/`)) return; - const relativePath = root ? repositoryPath.slice(root.length).replace(/^\//, '') : repositoryPath; - const parts = relativePath.split('/').filter(Boolean); - if (!parts.length || isHiddenMenuPath(parts.slice(0, -1))) return; - if (parts.slice(0, folderParts.length).join('/') !== folderPath) return; - const remaining = parts.slice(folderParts.length); - if (remaining.length > 1) { - if (!remaining[0].startsWith('.')) childDirectories.add(remaining[0]); - return; - } - const name = remaining[0]; - if (name === '.gitkeep' || name.startsWith('.')) return; - if (name.endsWith('.md')) childFiles.add(name.slice(0, -3)); - }); - - const directories = [...childDirectories] - .sort((a, b) => a.localeCompare(b)) - .map((name) => { - const path = [...folderParts, name]; - return { id: `github-dir-${path.join('-')}`, name, isFile: false, path }; - }); - const files = [...childFiles] - .sort((a, b) => a.localeCompare(b)) - .map((name) => { - const path = [...folderParts, name]; - return { id: `github-file-${path.join('-')}`, name, isFile: true, path }; - }); - return [...directories, ...files]; -}; - -export const createOperationId = () => window.crypto.randomUUID(); - export const validateWorkspaceName = (name: string) => { const value = name.trim(); if (!value || value === '.' || value === '..' || /[\\/\u0000-\u001f]/.test(value)) { @@ -94,8 +20,8 @@ export const validateWorkspaceName = (name: string) => { return value.endsWith('.md') ? value.slice(0, -3) : value; }; -export const getVisibleLogicalPath = (workspace: GitHubWorkspaceLike, repositoryPath: string) => { - const root = cleanPath(workspace.config.docsRoot); +export const getVisibleLogicalPath = (config: WorkspaceDescriptor, repositoryPath: string) => { + const root = cleanPath(config.docsRoot); const relative = root ? repositoryPath.slice(root.length).replace(/^\//, '') : repositoryPath; return normalizePath(relative.endsWith('.md') ? relative.slice(0, -3) : relative); }; diff --git a/client/src/utils/githubWorkspaceDb.ts b/client/src/utils/githubWorkspaceDb.ts deleted file mode 100644 index 44c4c62..0000000 --- a/client/src/utils/githubWorkspaceDb.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ -import type { PersistedGitHubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; - -const DATABASE_NAME = 'markdown-editor-github-workspaces'; -const STORE_NAME = 'workspaces'; -const DATABASE_VERSION = 1; - -const openDatabase = async (): Promise => - new Promise((resolve, reject) => { - const request = window.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); - request.onerror = () => { - reject(request.error); - }; - request.onsuccess = () => { - resolve(request.result); - }; - request.onupgradeneeded = () => { - if (!request.result.objectStoreNames.contains(STORE_NAME)) request.result.createObjectStore(STORE_NAME); - }; - }); - -export const readGitHubWorkspace = async (key: string): Promise => { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const request = database.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(key); - request.onerror = () => { - reject(request.error); - }; - request.onsuccess = () => { - resolve((request.result as PersistedGitHubWorkspace | undefined) ?? null); - }; - }).finally(() => { - database.close(); - }); -}; - -export const writeGitHubWorkspace = async (key: string, value: PersistedGitHubWorkspace): Promise => { - const database = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = database.transaction(STORE_NAME, 'readwrite'); - transaction.onerror = () => { - reject(transaction.error); - }; - transaction.oncomplete = () => { - resolve(); - }; - transaction.objectStore(STORE_NAME).put(value, key); - }).finally(() => { - database.close(); - }); -}; diff --git a/client/src/utils/githubWorkspaceRuntime.ts b/client/src/utils/githubWorkspaceRuntime.ts new file mode 100644 index 0000000..1c54750 --- /dev/null +++ b/client/src/utils/githubWorkspaceRuntime.ts @@ -0,0 +1,11 @@ +import { GitWorkspaceStore, type WorkspaceSnapshot } from '@markdown-editor/github-workspace'; +import { useSyncExternalStore } from 'react'; + +export const githubWorkspaceStore = new GitWorkspaceStore(); + +export const useGitHubWorkspaceSnapshot = (): WorkspaceSnapshot => + useSyncExternalStore( + githubWorkspaceStore.subscribe, + githubWorkspaceStore.getSnapshot, + githubWorkspaceStore.getSnapshot, + ); diff --git a/client/src/utils/hooks/reduxHooks.ts b/client/src/utils/hooks/reduxHooks.ts index 0d156ad..dd8e5ea 100644 --- a/client/src/utils/hooks/reduxHooks.ts +++ b/client/src/utils/hooks/reduxHooks.ts @@ -104,7 +104,6 @@ export const useSwitchTheme = () => { }; export const useDeleteTab = () => { - const tabs = useSelector(selectCurTabs); const githubWorkspace = useSelector(selectGithubWorkspace); const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const dispatch = useDispatch(); @@ -114,6 +113,7 @@ export const useDeleteTab = () => { const hasDraftFor = (path: string) => selectHasDraft(getDraftKey(workspaceKey, path))(store.getState()); return async (deletePaths: string[], options: { force?: boolean } = {}) => { + const tabs = selectCurTabs(store.getState()); const hasUnsaved = deletePaths.some((p) => hasDraftFor(p)); if (hasUnsaved && !options.force) { const message = @@ -184,12 +184,12 @@ interface TabRename { export const useRenameTabs = () => { const { navigate, curPath } = useCurPath(); - const tabs = useSelector(selectCurTabs); const githubWorkspace = useSelector(selectGithubWorkspace); const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const dispatch = useDispatch(); return (operations: TabRename[]) => { + const tabs = selectCurTabs(store.getState()); const renames: { oldPath: string; newPath: string }[] = []; const currentPath = normalizePath(curPath); const renamePath = (path: string) => { diff --git a/client/src/utils/hooks/workspaceHooks.ts b/client/src/utils/hooks/workspaceHooks.ts index 83e6ccb..70f83d7 100644 --- a/client/src/utils/hooks/workspaceHooks.ts +++ b/client/src/utils/hooks/workspaceHooks.ts @@ -1,9 +1,8 @@ -/* eslint-disable @typescript-eslint/no-dynamic-delete */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ /* eslint-disable @typescript-eslint/require-await */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import { QueryStatus, skipToken } from '@reduxjs/toolkit/query'; import { useCallback, useEffect, useMemo } from 'react'; -import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useSelector } from 'react-redux'; import type { Article, @@ -15,6 +14,7 @@ import type { UpdateDocPayload, } from '@/redux-api/docsApiType'; import type { RootState } from '@/store'; +import type { WorkspaceEntry } from '@markdown-editor/github-workspace'; import { useCopyCutDocMutation, @@ -28,95 +28,116 @@ import { } from '@/redux-api/docs'; import { useGetGitHubBlobQuery, useLoadGitHubWorkspaceQuery } from '@/redux-api/github'; import { - applyWorkingEntries, getGitHubWorkspaceKey, - hydrateGitHubWorkspace, selectGithubWorkspace, - syncRemoteWorkspace, - type GitHubOverlayEntry, + type GitHubWorkspaceConfig, } from '@/redux-feature/githubWorkspaceSlice'; -import { - createOperationId, - getEffectiveGitHubEntries, - getGitHubSubItems, - getRepositoryDocPath, - validateWorkspaceName, -} from '@/utils/githubWorkspace'; -import { readGitHubWorkspace } from '@/utils/githubWorkspaceDb'; +import { getRepositoryDocPath, getVisibleLogicalPath, validateWorkspaceName } from '@/utils/githubWorkspace'; +import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import { denormalizePath, normalizePath } from '@/utils/utils'; -// Kept as a single adapter boundary so local components never need to know which backend is active. +const MARKDOWN_EXTENSION_LENGTH = 3; + +const isConfigured = (config: GitHubWorkspaceConfig) => Boolean(config.owner && config.repo && config.branch); + +const isHiddenMenuEntry = (entry: WorkspaceEntry) => { + const name = entry.path.split('/').at(-1) ?? ''; + return name === '.gitkeep' || name === '_assets' || name.startsWith('.'); +}; + +const toDocTreeNode = (config: GitHubWorkspaceConfig, entry: WorkspaceEntry): DocTreeNode | null => { + if (isHiddenMenuEntry(entry)) return null; + const name = entry.path.split('/').at(-1) ?? ''; + if (entry.kind === 'file' && !name.endsWith('.md')) return null; + const logicalPath = getVisibleLogicalPath(config, entry.path); + const path = denormalizePath(logicalPath); + return { + id: entry.id, + name: entry.kind === 'file' ? name.slice(0, -MARKDOWN_EXTENSION_LENGTH) : name, + isFile: entry.kind === 'file', + path, + }; +}; + +const listGitHubSubItems = (config: GitHubWorkspaceConfig, logicalFolderPath = '') => { + const repositoryPath = getRepositoryDocPath(config, logicalFolderPath, false); + return githubWorkspaceStore + .listDirectory(repositoryPath) + .map((entry) => toDocTreeNode(config, entry)) + .filter((entry): entry is DocTreeNode => Boolean(entry)); +}; + +/** Activates the keyed package store, then attaches the RTK-fetched remote manifest. */ export const useGitHubWorkspaceSync = () => { - const dispatch = useDispatch(); const workspace = useSelector(selectGithubWorkspace); - const key = getGitHubWorkspaceKey(workspace.config); - const configured = Boolean(workspace.config.owner && workspace.config.repo && workspace.config.branch); + const snapshot = useGitHubWorkspaceSnapshot(); + const configured = isConfigured(workspace.config); + const activeWorkspace = snapshot.descriptor + ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) + : false; useEffect(() => { - if (workspace.mode !== 'github' || !configured || workspace.hydratedKey === key) return; - let active = true; - void readGitHubWorkspace(key) - .then((persisted) => { - if (active) dispatch(hydrateGitHubWorkspace(persisted)); - }) - .catch(() => { - if (active) dispatch(hydrateGitHubWorkspace(null)); - }); - return () => { - active = false; - }; - }, [configured, dispatch, key, workspace.hydratedKey, workspace.mode]); + if (workspace.mode !== 'github' || !configured) { + githubWorkspaceStore.close(); + return; + } + void githubWorkspaceStore.open(workspace.config); + }, [configured, workspace.config, workspace.mode]); const query = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && workspace.hydratedKey === key ? workspace.config : skipToken, + workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, ); useEffect(() => { - if (query.data) dispatch(syncRemoteWorkspace(query.data)); - }, [dispatch, query.data]); + if (query.data) void githubWorkspaceStore.attachRemote(query.data); + }, [query.data]); return query; }; export const useWorkspaceRootItemsQuery = () => { const workspace = useSelector(selectGithubWorkspace); + const snapshot = useGitHubWorkspaceSnapshot(); const localQuery = useGetDocSubItemsQuery(undefined, { skip: workspace.mode === 'github', refetchOnMountOrArgChange: true, }); - const key = getGitHubWorkspaceKey(workspace.config); - const configured = Boolean(workspace.config.owner && workspace.config.repo && workspace.config.branch); + const configured = isConfigured(workspace.config); + const activeWorkspace = snapshot.descriptor + ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) + : false; const githubQuery = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && workspace.hydratedKey === key ? workspace.config : skipToken, + workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, + ); + const data = useMemo( + () => + workspace.mode === 'github' && activeWorkspace && snapshot.hydrated ? listGitHubSubItems(workspace.config) : [], + [activeWorkspace, snapshot.revision, snapshot.hydrated, workspace.config, workspace.mode], ); - const data = useMemo(() => getGitHubSubItems(workspace), [workspace]); if (workspace.mode === 'local') return localQuery; - const ready = Boolean(workspace.hydratedKey && workspace.config.baseCommitSha); return { data, error: githubQuery.error, isError: githubQuery.isError, - isFetching: configured && !ready && githubQuery.isFetching, - isSuccess: ready || !configured, + isFetching: + configured && (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) && !githubQuery.isError, + isSuccess: (activeWorkspace && snapshot.hydrated && Boolean(snapshot.baseCommitSha)) || !configured, refetch: async () => ({ data }), }; }; export const useLazyWorkspaceSubItemsQuery = () => { - const store = useStore(); + const workspace = useSelector(selectGithubWorkspace); const [localTrigger] = useLazyGetDocSubItemsQuery(); const trigger = useCallback( async (params: { folderDocPath?: string } = {}) => { - // A menu mutation dispatches its overlay before asking for the refreshed children. Reading the store here, - // rather than capturing `workspace` during render, ensures that refresh sees the just-created/moved paths. - const workspace = store.getState().githubWorkspace; if (workspace.mode === 'local') return localTrigger(params); return { - data: getGitHubSubItems(workspace, params.folderDocPath), + data: listGitHubSubItems(workspace.config, params.folderDocPath), status: QueryStatus.fulfilled, }; }, - [localTrigger, store], + [localTrigger, workspace.config, workspace.mode], ); return [trigger] as const; }; @@ -130,22 +151,24 @@ const getDefaultArticle = (path: string, content: string): Article => ({ export const useWorkspaceDocQuery = (logicalPath: string) => { const workspace = useSelector(selectGithubWorkspace); + const snapshot = useGitHubWorkspaceSnapshot(); const localQuery = useGetDocQuery(logicalPath, { skip: workspace.mode === 'github' }); - const key = getGitHubWorkspaceKey(workspace.config); - const configured = Boolean(workspace.config.owner && workspace.config.repo && workspace.config.branch); + const configured = isConfigured(workspace.config); + const activeWorkspace = snapshot.descriptor + ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) + : false; const workspaceQuery = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && workspace.hydratedKey === key ? workspace.config : skipToken, + workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, ); const repositoryPath = getRepositoryDocPath(workspace.config, logicalPath, true); - const effectiveEntry = getEffectiveGitHubEntries(workspace)[repositoryPath]; + const resolvedPath = githubWorkspaceStore.resolveMovedPath(repositoryPath); + const source = githubWorkspaceStore.getFileSource(resolvedPath); const blobQuery = useGetGitHubBlobQuery( - workspace.mode === 'github' && effectiveEntry?.sha && effectiveEntry.content === undefined - ? { owner: workspace.config.owner, repo: workspace.config.repo, sha: effectiveEntry.sha } + workspace.mode === 'github' && source?.kind === 'remote' && source.sha + ? { owner: workspace.config.owner, repo: workspace.config.repo, sha: source.sha } : skipToken, ); - // Overlay content wins for new/edited documents. An untouched or SHA-only moved document lazily reuses its - // remote blob, so moving a file does not download and re-upload it merely to change its path. - const githubContent = effectiveEntry?.content ?? blobQuery.currentData; + const githubContent = source?.kind === 'remote' ? blobQuery.currentData : source?.content; const githubArticle = useMemo( () => (githubContent === undefined ? undefined : getDefaultArticle(logicalPath, githubContent)), [githubContent, logicalPath], @@ -153,182 +176,62 @@ export const useWorkspaceDocQuery = (logicalPath: string) => { const missingDocumentError = useMemo(() => new Error(`The file ${logicalPath} does not exist.`), [logicalPath]); if (workspace.mode === 'local') return localQuery; - const workspaceReady = workspace.hydratedKey === key && Boolean(workspace.config.baseCommitSha); - if (!workspaceReady) { + if (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) { return { data: undefined, error: workspaceQuery.error, isSuccess: false, - isFetching: !workspaceQuery.isError, + isFetching: !activeWorkspace || !snapshot.hydrated || !workspaceQuery.isError, }; } - return { data: githubArticle, - error: effectiveEntry ? blobQuery.error : missingDocumentError, + error: source ? blobQuery.error : missingDocumentError, isSuccess: githubArticle !== undefined, - isFetching: Boolean(effectiveEntry && githubArticle === undefined && blobQuery.isFetching), + isFetching: Boolean(source && githubArticle === undefined && blobQuery.isFetching), }; }; -const createOverlay = ( - entry: Partial & Pick, -): GitHubOverlayEntry => ({ mode: '100644', type: 'blob', ...entry }); - -const getKeepPath = (parentPath: string) => (parentPath ? `${parentPath}/.gitkeep` : null); - -/** - * Git has files, not directories. After deletes/moves, add a `.gitkeep` only when the resulting effective tree has - * no descendant under a parent. These marker entries belong to the same operation group as the mutation that made - * the directory empty, and are hidden by the menu adapter. - */ -const addEmptyParentMarkers = ( - workspace: ReturnType, - overlays: GitHubOverlayEntry[], - parentPaths: string[], -) => { - const effective = getEffectiveGitHubEntries(workspace); - overlays.forEach((overlay) => { - if (overlay.action === 'delete') delete effective[overlay.path]; - else { - effective[overlay.path] = { - path: overlay.path, - mode: overlay.mode, - type: overlay.type, - sha: overlay.sha ?? '', - content: overlay.content, - }; - } - }); - parentPaths.forEach((parentPath) => { - if (!parentPath || Object.keys(effective).some((path) => path.startsWith(`${parentPath}/`))) return; - const operationId = overlays[0]?.operationId ?? createOperationId(); - overlays.push( - createOverlay({ - path: `${parentPath}/.gitkeep`, - action: 'upsert', - content: '', - operationId, - label: overlays[0]?.label ?? `Keep empty folder ${parentPath}`, - }), - ); - }); -}; - export const useCreateWorkspaceDoc = () => { - const dispatch = useDispatch(); const workspace = useSelector(selectGithubWorkspace); const [localCreate] = useCreateDocMutation(); return async (payload: CreateDocPayload): Promise => { if (workspace.mode === 'local') return localCreate(payload).unwrap(); - const logicalParts = denormalizePath(payload.filePath).filter(Boolean); const name = validateWorkspaceName(logicalParts.at(-1) ?? ''); logicalParts[logicalParts.length - 1] = name; const logicalPath = normalizePath(logicalParts); - const repositoryPath = payload.isFile - ? getRepositoryDocPath(workspace.config, logicalPath, true) - : `${getRepositoryDocPath(workspace.config, logicalPath, false)}/.gitkeep`; - if (getEffectiveGitHubEntries(workspace)[repositoryPath]) { - throw new Error(`${logicalParts.join('/')} already exists.`); - } - // A file is represented directly; an empty directory exists in Git only through its hidden `.gitkeep` blob. - const operationId = createOperationId(); - const entries: GitHubOverlayEntry[] = [ - createOverlay({ - path: repositoryPath, - action: 'upsert', - content: '', - operationId, - label: `Create ${payload.isFile ? 'file' : 'folder'} ${logicalParts.join('/')}`, - }), - ]; - const parentKeepPath = getKeepPath( - getRepositoryDocPath(workspace.config, normalizePath(logicalParts.slice(0, -1)), false), - ); - if (parentKeepPath && getEffectiveGitHubEntries(workspace)[parentKeepPath]) { - entries.push(createOverlay({ path: parentKeepPath, action: 'delete', operationId, label: entries[0].label })); - } - dispatch(applyWorkingEntries(entries)); - return { - id: `github-${logicalParts.join('-')}`, - name, - isFile: payload.isFile, - path: logicalParts, - }; + const repositoryPath = getRepositoryDocPath(workspace.config, logicalPath, payload.isFile); + if (payload.isFile) await githubWorkspaceStore.createFile(repositoryPath); + else await githubWorkspaceStore.createDirectory(repositoryPath); + return { id: `github-${logicalParts.join('-')}`, name, isFile: payload.isFile, path: logicalParts }; }; }; export const useUpdateWorkspaceDoc = () => { - const dispatch = useDispatch(); const workspace = useSelector(selectGithubWorkspace); const [localUpdate] = useUpdateDocMutation(); return async (payload: UpdateDocPayload) => { if (workspace.mode === 'local') return localUpdate(payload).unwrap(); - const path = getRepositoryDocPath(workspace.config, payload.filePath, true); - const current = getEffectiveGitHubEntries(workspace)[path]; - const currentWorking = workspace.working[path]; - const canMergeIntoWorkingOperation = currentWorking?.action === 'upsert' && !workspace.staged[path]; - dispatch( - applyWorkingEntries([ - createOverlay({ - path, - action: 'upsert', - content: payload.content, - sha: current?.sha, - mode: current?.mode ?? '100644', - type: current?.type === 'commit' ? 'commit' : 'blob', - // Repeated saves, and edits to a newly-created/moved file, refine the existing unstaged operation. Once a - // snapshot is staged we deliberately start a new operation so the staged content remains publishable. - operationId: canMergeIntoWorkingOperation ? currentWorking.operationId : createOperationId(), - label: canMergeIntoWorkingOperation - ? currentWorking.label - : `Update ${denormalizePath(payload.filePath).join('/')}`, - }), - ]), + await githubWorkspaceStore.writeFile( + getRepositoryDocPath(workspace.config, payload.filePath, true), + payload.content, ); }; }; export const useDeleteWorkspaceDocs = () => { - const dispatch = useDispatch(); const workspace = useSelector(selectGithubWorkspace); const [localDelete] = useDeleteDocMutation(); return async (payload: DeleteDocPayload) => { if (workspace.mode === 'local') return localDelete(payload).unwrap(); - - // Generate tombstones from the effective tree (base plus working), not only the remote manifest. This lets a - // delete cancel an uncommitted creation and includes hidden/non-Markdown descendants in directory deletes. - const effective = getEffectiveGitHubEntries(workspace); - const overlays: GitHubOverlayEntry[] = []; - const parentPaths: string[] = []; - payload.forEach(({ filePath, isFile }) => { - const operationId = createOperationId(); - const source = getRepositoryDocPath(workspace.config, filePath, isFile); - parentPaths.push(source.split('/').slice(0, -1).join('/')); - Object.values(effective).forEach((entry) => { - if ((isFile && entry.path === source) || (!isFile && entry.path.startsWith(`${source}/`))) { - overlays.push( - createOverlay({ - path: entry.path, - action: 'delete', - mode: entry.mode, - type: entry.type === 'tree' ? 'blob' : entry.type, - operationId, - label: `Delete ${denormalizePath(filePath).join('/')}`, - scopePaths: [source], - }), - ); - } - }); - }); - addEmptyParentMarkers(workspace, overlays, parentPaths); - dispatch(applyWorkingEntries(overlays)); + for (const item of payload) { + await githubWorkspaceStore.delete(getRepositoryDocPath(workspace.config, item.filePath, item.isFile)); + } }; }; export const useCopyCutWorkspaceDocs = () => { - const dispatch = useDispatch(); const workspace = useSelector(selectGithubWorkspace); const [localCopyCut] = useCopyCutDocMutation(); return async (payload: CopyCutDocPayload, onApplied?: () => void) => { @@ -337,78 +240,12 @@ export const useCopyCutWorkspaceDocs = () => { onApplied?.(); return result; } - - // Moves/copies are expressed as a single atomic overlay group: destination upserts plus source tombstones. Blob - // SHAs are reused whenever content was not edited. `applyWorkingEntries` later cancels source paths that only - // existed as unstaged creations, leaving the net tree instead of a create-then-delete history. - const effective = getEffectiveGitHubEntries(workspace); - const overlays: GitHubOverlayEntry[] = []; - const emptiedParentPaths: string[] = []; - payload.forEach(({ copyCutPath, pastePath, isCopy, isFile }) => { - const source = getRepositoryDocPath(workspace.config, copyCutPath, isFile); - const destination = getRepositoryDocPath(workspace.config, pastePath, isFile); - const sourceLabelPath = denormalizePath(copyCutPath).join('/'); - const destinationLabelPath = denormalizePath(pastePath).join('/'); - const sourceWorkingEntry = workspace.working[source]; - const isUnstagedFileCreation = Boolean( - !isCopy && - isFile && - !workspace.baseEntries[source] && - sourceWorkingEntry?.action === 'upsert' && - !sourceWorkingEntry.sha && - sourceWorkingEntry.content !== undefined && - !workspace.staged[source], - ); - const operationId = isUnstagedFileCreation ? sourceWorkingEntry.operationId : createOperationId(); - const operationLabel = isUnstagedFileCreation - ? `Create file ${destinationLabelPath}` - : `${isCopy ? 'Copy' : 'Move'} ${sourceLabelPath} to ${destinationLabelPath}`; - const destinationParent = destination.split('/').slice(0, -1).join('/'); - const destinationKeep = getKeepPath(destinationParent); - if (destinationKeep && effective[destinationKeep]) { - overlays.push( - createOverlay({ - path: destinationKeep, - action: 'delete', - operationId, - label: operationLabel, - }), - ); - } - if (!isCopy) emptiedParentPaths.push(source.split('/').slice(0, -1).join('/')); - Object.values(effective).forEach((entry) => { - if (!((isFile && entry.path === source) || (!isFile && entry.path.startsWith(`${source}/`)))) return; - const destinationPath = isFile ? destination : `${destination}${entry.path.slice(source.length)}`; - overlays.push( - createOverlay({ - path: destinationPath, - action: 'upsert', - mode: entry.mode, - type: entry.type === 'tree' ? 'blob' : entry.type, - sha: entry.sha || undefined, - content: entry.content, - operationId, - label: operationLabel, - scopePaths: [source, destination], - }), - ); - if (!isCopy) { - overlays.push( - createOverlay({ - path: entry.path, - action: 'delete', - mode: entry.mode, - type: entry.type === 'tree' ? 'blob' : entry.type, - operationId, - label: overlays.at(-1)!.label, - scopePaths: [source, destination], - }), - ); - } - }); - }); - addEmptyParentMarkers(workspace, overlays, emptiedParentPaths); - dispatch(applyWorkingEntries(overlays)); + for (const item of payload) { + const source = getRepositoryDocPath(workspace.config, item.copyCutPath, item.isFile); + const destination = getRepositoryDocPath(workspace.config, item.pastePath, item.isFile); + if (item.isCopy) await githubWorkspaceStore.copy(source, destination); + else await githubWorkspaceStore.move(source, destination); + } onApplied?.(); }; }; @@ -427,14 +264,7 @@ export const useRenameWorkspaceDoc = () => { const name = validateWorkspaceName(payload.name); const newPath = normalizePath([...pathParts.slice(0, -1), name]); return copyCut( - [ - { - copyCutPath: payload.filePath, - pastePath: newPath, - isCopy: false, - isFile: payload.isFile, - }, - ], + [{ copyCutPath: payload.filePath, pastePath: newPath, isCopy: false, isFile: payload.isFile }], onApplied, ); }; diff --git a/client/tsconfig.json b/client/tsconfig.json index f804cde..6e20cb3 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -18,7 +18,8 @@ "noEmit": true, "allowJs": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@markdown-editor/*": ["./packages/*/src"] }, "types": ["@types/node"] }, diff --git a/package.json b/package.json index 537b15f..c8cbc50 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:macos": "pnpm --filter crates build-macos", "build:windows": "pnpm --filter crates build-windows", "build:all": "pnpm --filter crates build", - "type-check": "pnpm --filter client type-check && pnpm --filter github-auth-worker type-check", + "type-check": "pnpm --filter @markdown-editor/github-workspace type-check && pnpm --filter client type-check && pnpm --filter github-auth-worker type-check", "type-check:full": "pnpm --filter github-auth-worker cf-typegen && pnpm type-check", "lint": "eslint client/**/*.{ts,tsx} github-auth-worker/src/**/*.ts --fix", "release": "tsx scripts/bump-version.ts", diff --git a/packages/github-workspace/package.json b/packages/github-workspace/package.json new file mode 100644 index 0000000..57b69cb --- /dev/null +++ b/packages/github-workspace/package.json @@ -0,0 +1,15 @@ +{ + "name": "@markdown-editor/github-workspace", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "npm:@typescript/typescript6@^6.0.2" + } +} diff --git a/packages/github-workspace/src/constants.ts b/packages/github-workspace/src/constants.ts new file mode 100644 index 0000000..f626a3e --- /dev/null +++ b/packages/github-workspace/src/constants.ts @@ -0,0 +1,6 @@ +export const DATABASE_NAME = 'markdown-editor-github-workspaces'; +export const WORKSPACE_STORE_NAME = 'workspaces-v3'; +export const DATABASE_VERSION = 3; +export const WORKSPACE_SCHEMA_VERSION = 3; +export const EMPTY_DIRECTORY_MARKER = '.gitkeep'; +export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json'; diff --git a/packages/github-workspace/src/git-status.ts b/packages/github-workspace/src/git-status.ts new file mode 100644 index 0000000..8334114 --- /dev/null +++ b/packages/github-workspace/src/git-status.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ +import { EMPTY_DIRECTORY_MARKER } from './constants'; +import { GitMode } from './types'; +import { cleanPath, makeMetadata, parentPath } from './utils'; + +import type { + ChangeMetadata, + PathMapping, + RemoteTreeEntry, + TreeState, + WorkspaceChange, + WorkspaceDescriptor, + WorkspaceEntry, + WorkspaceStatus, + PublishTreeEntry, +} from './types'; + +export const emptyTree = (): TreeState => ({ entries: {}, deletedMetadata: {} }); + +export const cloneEntry = (entry: WorkspaceEntry): WorkspaceEntry => ({ + ...entry, + metadata: entry.metadata ? { ...entry.metadata, scopePaths: [...entry.metadata.scopePaths] } : undefined, +}); + +export const cloneMetadata = (metadata: ChangeMetadata): ChangeMetadata => ({ + groupId: metadata.groupId, + label: metadata.label, + scopePaths: [...metadata.scopePaths], +}); + +export const cloneTree = (tree: TreeState): TreeState => ({ + entries: Object.fromEntries(Object.entries(tree.entries).map(([path, entry]) => [path, cloneEntry(entry)])), + deletedMetadata: Object.fromEntries( + Object.entries(tree.deletedMetadata).map(([path, metadata]) => [ + path, + { ...metadata, scopePaths: [...metadata.scopePaths] }, + ]), + ), +}); + +export const createDirectoryEntry = (path: string): WorkspaceEntry => ({ + id: `directory:${path}`, + path, + kind: 'directory', + mode: GitMode.Directory, + type: 'tree', +}); + +export const getDirectoryPaths = (path: string, root: string) => { + const parts = path.split('/').filter(Boolean); + const rootLength = root ? root.split('/').filter(Boolean).length : 0; + return parts.slice(0, -1).reduce((directories, _, index) => { + const directory = parts.slice(0, index + 1).join('/'); + if (index + 1 > rootLength) directories.push(directory); + return directories; + }, []); +}; + +export const createBaseTree = (descriptor: WorkspaceDescriptor, entries: RemoteTreeEntry[]): TreeState => { + const root = cleanPath(descriptor.docsRoot); + const tree = emptyTree(); + entries.forEach((entry) => { + if (root && entry.path !== root && !entry.path.startsWith(`${root}/`)) return; + getDirectoryPaths(entry.path, root).forEach((directory) => { + tree.entries[directory] ??= createDirectoryEntry(directory); + }); + tree.entries[entry.path] = { + id: `base:${entry.path}`, + path: entry.path, + kind: 'file', + mode: entry.mode, + type: entry.type, + sha: entry.sha, + size: entry.size, + }; + }); + return tree; +}; + +const sameEntry = (left?: WorkspaceEntry, right?: WorkspaceEntry) => + Boolean( + left && + right && + left.kind === right.kind && + left.mode === right.mode && + left.type === right.type && + left.sha === right.sha && + left.content === right.content, + ); + +const entryMetadata = (tree: TreeState, path: string, entry?: WorkspaceEntry) => + entry?.metadata ?? tree.deletedMetadata[path]; + +const isEmptyDirectory = (tree: TreeState, path: string) => + !Object.values(tree.entries).some( + (entry) => parentPath(entry.path) === path && entry.path !== `${path}/${EMPTY_DIRECTORY_MARKER}`, + ); + +export const treeToGitStatus = ( + from: TreeState, + to: TreeState, + addedStatus: 'ADDED' | 'UNTRACKED', +): WorkspaceChange[] => { + const paths = new Set([...Object.keys(from.entries), ...Object.keys(to.entries)]); + return [...paths] + .sort((left, right) => left.localeCompare(right)) + .flatMap((path) => { + const oldEntry = from.entries[path]; + const newEntry = to.entries[path]; + if (sameEntry(oldEntry, newEntry)) return []; + // Directories are a UI concept rather than Git tree leaves. Only empty-directory changes need their own status. + if ( + (newEntry?.kind === 'directory' && !oldEntry && !isEmptyDirectory(to, path)) || + (oldEntry?.kind === 'directory' && !newEntry && !isEmptyDirectory(from, path)) + ) { + return []; + } + const metadata = + entryMetadata(to, path, newEntry) ?? makeMetadata(`${newEntry ? 'Update' : 'Delete'} ${path}`, [path]); + const status: WorkspaceStatus = !oldEntry ? addedStatus : !newEntry ? 'DELETED' : 'MODIFIED'; + return [ + { + id: `${status}:${path}`, + groupId: metadata.groupId, + label: metadata.label, + status, + path, + kind: (newEntry ?? oldEntry).kind, + oldEntry: oldEntry ? cloneEntry(oldEntry) : null, + newEntry: newEntry ? cloneEntry(newEntry) : null, + scopePaths: [...metadata.scopePaths], + }, + ]; + }); +}; + +export const getWorkingChanges = (index: TreeState, working: TreeState) => treeToGitStatus(index, working, 'UNTRACKED'); + +export const getStagedChanges = (base: TreeState, index: TreeState) => treeToGitStatus(base, index, 'ADDED'); + +export const gitStatusToTree = (base: TreeState, changes: WorkspaceChange[], root = '') => { + const result = cloneTree(base); + changes.forEach((change) => { + if (change.newEntry) { + result.entries[change.path] = cloneEntry(change.newEntry); + delete result.deletedMetadata[change.path]; + } else { + delete result.entries[change.path]; + result.deletedMetadata[change.path] = cloneMetadata(change); + } + }); + Object.values(result.entries) + .filter((entry) => entry.kind === 'file') + .forEach((entry) => { + getDirectoryPaths(entry.path, cleanPath(root)).forEach((path) => { + result.entries[path] ??= { + ...createDirectoryEntry(path), + metadata: entry.metadata ? cloneMetadata(entry.metadata) : undefined, + }; + }); + }); + Object.values(result.entries) + .filter((entry) => entry.kind === 'directory') + .forEach((entry) => { + const hasDescendant = Object.keys(result.entries).some((path) => path.startsWith(`${entry.path}/`)); + if (!hasDescendant) delete result.entries[entry.path]; + }); + return result; +}; + +export const stagedChangesToPublishEntries = (base: TreeState, index: TreeState, root = ''): PublishTreeEntry[] => + treeToGitStatus(base, gitStatusToTree(base, getStagedChanges(base, index), root), 'ADDED') + .filter((change) => change.kind === 'file') + .map((change) => { + if (!change.newEntry) { + return { + path: change.path, + mode: change.oldEntry?.mode ?? GitMode.File, + type: change.oldEntry?.type === 'commit' ? 'commit' : 'blob', + sha: null, + }; + } + return { + path: change.path, + mode: change.newEntry.mode, + type: change.newEntry.type === 'commit' ? 'commit' : 'blob', + sha: change.newEntry.content === undefined ? change.newEntry.sha ?? null : null, + content: change.newEntry.content, + }; + }); + +export const changedPaths = (left: TreeState, right: TreeState) => + treeToGitStatus(left, right, 'ADDED').map((change) => change.path); + +export const copyPathState = (source: TreeState, destination: TreeState, path: string) => { + const entry = source.entries[path]; + if (entry) destination.entries[path] = cloneEntry(entry); + else delete destination.entries[path]; + const metadata = source.deletedMetadata[path]; + if (metadata) destination.deletedMetadata[path] = { ...metadata, scopePaths: [...metadata.scopePaths] }; + else delete destination.deletedMetadata[path]; +}; + +export const applyGitStatusToTree = (from: TreeState, desired: TreeState, onto: TreeState, root = '') => { + return gitStatusToTree(onto, treeToGitStatus(from, desired, 'ADDED'), root); +}; + +export const getPathMappings = (before: TreeState, after: TreeState) => { + const beforeById = new Map(Object.values(before.entries).map((entry) => [entry.id, entry])); + return Object.values(after.entries).flatMap((entry) => { + const previous = beforeById.get(entry.id); + return previous && previous.path !== entry.path + ? [{ oldPath: previous.path, newPath: entry.path, kind: entry.kind }] + : []; + }); +}; diff --git a/packages/github-workspace/src/index.ts b/packages/github-workspace/src/index.ts new file mode 100644 index 0000000..670ef79 --- /dev/null +++ b/packages/github-workspace/src/index.ts @@ -0,0 +1,452 @@ +/** + * Browser-side Git workspace orchestration. + * + * - `base` is the immutable manifest at the selected remote commit. + * - `index` is the staged snapshot that will become the next commit. + * - `working` is the effective tree consumed by the menu and editor. + * + * Workspace operations mutate a tree, the status boundary derives/merges Git-like changes, and the persistence + * boundary stores the base plus normalized working/staged statuses before subscribers are notified. + */ + +import { WORKSPACE_SCHEMA_VERSION } from './constants'; +import { + applyGitStatusToTree, + changedPaths, + cloneTree, + createBaseTree, + emptyTree, + gitStatusToTree, + getPathMappings, + getStagedChanges, + getWorkingChanges, + stagedChangesToPublishEntries, +} from './git-status'; +import { createPersistedWorkspace, IndexedDbWorkspacePersistence } from './persistence'; +import { cleanPath, createId, getWorkspaceKey, makeMetadata, parentPath, pathsOverlap } from './utils'; +import { + assertInScope, + createDirectory, + createFileEntry, + deleteWorkspaceEntry, + getTransferMetadata, + getWorkspaceEntry, + listDirectoryEntries, + resolveMovedEntryPath, + restoreWorkingChanges, + stageChanges, + transferWorkspaceEntry, + unstageChanges, + writeFileEntry, +} from './workspace-operations'; + +import type { + ChangeMetadata, + MutationResult, + PathMapping, + PublishPlan, + RebaseResult, + RemoteWorkspaceSnapshot, + TreeState, + WorkspaceDescriptor, + WorkspacePersistence, + WorkspaceSnapshot, +} from './types'; + +export * from './constants'; +export * from './git-status'; +export { IndexedDbWorkspacePersistence } from './persistence'; +export * from './types'; +export { getWorkspaceKey } from './utils'; + +export class GitWorkspaceStore { + private descriptor: WorkspaceDescriptor | null = null; + + private key = ''; + + private baseCommitSha = ''; + + private baseTreeSha = ''; + + private base = emptyTree(); + + private index = emptyTree(); + + private working = emptyTree(); + + private revision = 0; + + private hydrated = false; + + private remoteStale = false; + + private publishingToken = ''; + + private readonly aliases = new Map(); + + private readonly listeners = new Set<() => void>(); + + private mutationQueue: Promise = Promise.resolve(); + + private snapshotCache: WorkspaceSnapshot | undefined; + + public constructor(private readonly persistence: WorkspacePersistence = new IndexedDbWorkspacePersistence()) {} + + public subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + public getSnapshot = (): WorkspaceSnapshot => { + this.snapshotCache ??= { + descriptor: this.descriptor ? { ...this.descriptor } : null, + baseCommitSha: this.baseCommitSha, + baseTreeSha: this.baseTreeSha, + revision: this.revision, + hydrated: this.hydrated, + remoteStale: this.remoteStale, + publishing: Boolean(this.publishingToken), + entries: cloneTree(this.working).entries, + workingChanges: this.getWorkingChanges(), + stagedChanges: this.getStagedChanges(), + }; + return this.snapshotCache; + }; + + public async open(descriptor: WorkspaceDescriptor) { + await this.mutationQueue.catch(() => undefined); + const key = getWorkspaceKey(descriptor); + if (this.key === key && this.hydrated) return; + this.descriptor = { ...descriptor, docsRoot: cleanPath(descriptor.docsRoot) }; + this.key = key; + this.hydrated = false; + this._emit(); + const persisted = await this.persistence.read(key); + if (this.key !== key) return; + if (persisted?.schemaVersion === WORKSPACE_SCHEMA_VERSION) { + this.baseCommitSha = persisted.baseCommitSha; + this.baseTreeSha = persisted.baseTreeSha; + this.base = cloneTree(persisted.base); + this.index = gitStatusToTree(this.base, persisted.stagedChanges, this.descriptor.docsRoot); + this.working = gitStatusToTree(this.index, persisted.workingChanges, this.descriptor.docsRoot); + this.revision = persisted.revision; + this.remoteStale = persisted.remoteStale; + } else { + this.baseCommitSha = ''; + this.baseTreeSha = ''; + this.base = emptyTree(); + this.index = emptyTree(); + this.working = emptyTree(); + this.revision = 0; + this.remoteStale = false; + } + this.hydrated = true; + this._emit(); + } + + public close() { + this.descriptor = null; + this.key = ''; + this.hydrated = false; + this.aliases.clear(); + this._emit(); + } + + public async attachRemote(snapshot: RemoteWorkspaceSnapshot) { + const expectedKey = this.key; + return this._enqueue(async () => { + if (!this.descriptor || this.key !== expectedKey) return; + const remote = createBaseTree(this.descriptor, snapshot.entries); + const pending = this.getWorkingChanges().length > 0 || this.getStagedChanges().length > 0; + if (this.baseCommitSha && this.baseCommitSha !== snapshot.baseCommitSha && pending) { + this.remoteStale = true; + await this._persistAndEmit(); + return; + } + if (!pending || !this.baseCommitSha) { + this.base = remote; + this.index = cloneTree(remote); + this.working = cloneTree(remote); + } + this.baseCommitSha = snapshot.baseCommitSha; + this.baseTreeSha = snapshot.baseTreeSha; + this.remoteStale = false; + await this._persistAndEmit(); + }); + } + + public getWorkingChanges() { + return getWorkingChanges(this.index, this.working); + } + + public getStagedChanges() { + return getStagedChanges(this.base, this.index); + } + + public listDirectory(path: string) { + return listDirectoryEntries(this.working, path); + } + + public getEntry(path: string) { + return getWorkspaceEntry(this.working, this.aliases, path); + } + + public getFileSource(path: string) { + const entry = this.getEntry(path); + if (!entry || entry.kind !== 'file') return undefined; + if (entry.content !== undefined) return { kind: 'local', content: entry.content, sha: entry.sha } as const; + return { kind: 'remote', sha: entry.sha } as const; + } + + public resolveMovedPath(path: string) { + return resolveMovedEntryPath(this.working, this.aliases, path); + } + + public async createFile(path: string, content = '') { + assertInScope(this.descriptor, path); + return this._mutate(`Create file ${path}`, [path], (tree, metadata) => { + createFileEntry(tree, this._requireDescriptor(), path, content, metadata); + }); + } + + public async createDirectory(path: string) { + assertInScope(this.descriptor, path); + return this._mutate(`Create folder ${path}`, [path], (tree, metadata) => { + createDirectory(tree, this._requireDescriptor(), path, metadata); + }); + } + + public async writeFile(path: string, content: string) { + assertInScope(this.descriptor, path); + const current = this.working.entries[path]; + if (!current || current.kind !== 'file') return Promise.reject(new Error(`The file ${path} does not exist.`)); + const metadata = current.metadata ?? makeMetadata(`Update ${path}`, [path]); + return this._mutateWithMetadata(metadata, (tree) => { + writeFileEntry(tree, path, content, metadata); + }); + } + + public async delete(path: string) { + assertInScope(this.descriptor, path); + return this._mutate(`Delete ${path}`, [path], (tree, metadata) => { + deleteWorkspaceEntry(tree, this.base, path, metadata); + }); + } + + public async move(source: string, destination: string) { + return this._transfer(source, destination, false); + } + + public async copy(source: string, destination: string) { + return this._transfer(source, destination, true); + } + + public async stage(groupIds: string[]) { + return this._enqueue(async () => { + this._assertStagingUnlocked(); + stageChanges(this.base, this.index, this.working, groupIds); + this.revision += 1; + await this._persistAndEmit(); + return this._result(); + }); + } + + public async stageAll() { + return this._enqueue(async () => { + this._assertStagingUnlocked(); + this.index = cloneTree(this.working); + this.revision += 1; + await this._persistAndEmit(); + return this._result(); + }); + } + + public async unstage(groupIds: string[]) { + return this._enqueue(async () => { + this._assertStagingUnlocked(); + unstageChanges(this.base, this.index, groupIds); + this.revision += 1; + await this._persistAndEmit(); + return this._result(); + }); + } + + public async restoreWorking(groupIds: string[]) { + return this._enqueue(async () => { + const before = cloneTree(this.working); + restoreWorkingChanges(this.index, this.working, groupIds); + this.revision += 1; + await this._persistAndEmit(); + return this._result(getPathMappings(before, this.working)); + }); + } + + public async discardAll() { + return this._enqueue(async () => { + const before = cloneTree(this.working); + this.index = cloneTree(this.base); + this.working = cloneTree(this.base); + this.remoteStale = false; + this.revision += 1; + await this._persistAndEmit(); + return this._result(getPathMappings(before, this.working)); + }); + } + + public async rebase(snapshot: RemoteWorkspaceSnapshot): Promise { + return this._enqueue(async () => { + const descriptor = this._requireDescriptor(); + const remote = createBaseTree(descriptor, snapshot.entries); + const remoteChanges = changedPaths(this.base, remote); + const scopes = [...this.getWorkingChanges(), ...this.getStagedChanges()].flatMap((change) => change.scopePaths); + const conflicts = remoteChanges.filter((path) => scopes.some((scope) => pathsOverlap(path, scope))); + if (conflicts.length) { + this.remoteStale = true; + await this._persistAndEmit(); + return { conflicts, rebased: false }; + } + const oldBase = this.base; + this.base = remote; + this.index = applyGitStatusToTree(oldBase, this.index, remote, descriptor.docsRoot); + this.working = applyGitStatusToTree(oldBase, this.working, this.index, descriptor.docsRoot); + this.baseCommitSha = snapshot.baseCommitSha; + this.baseTreeSha = snapshot.baseTreeSha; + this.remoteStale = false; + this.revision += 1; + await this._persistAndEmit(); + return { conflicts: [], rebased: true }; + }); + } + + public beginPublish(): PublishPlan { + if (this.publishingToken) throw new Error('A publish is already in progress.'); + if (this.remoteStale) throw new Error('Rebase the GitHub workspace before publishing.'); + const token = createId(); + this.publishingToken = token; + this._emit(); + return { + token, + baseCommitSha: this.baseCommitSha, + baseTreeSha: this.baseTreeSha, + entries: stagedChangesToPublishEntries(this.base, this.index, this.descriptor?.docsRoot), + }; + } + + public async completePublish(snapshot: RemoteWorkspaceSnapshot, token: string) { + return this._enqueue(async () => { + if (token !== this.publishingToken) throw new Error('The publish transaction is no longer active.'); + const descriptor = this._requireDescriptor(); + const previousIndex = this.index; + const nextBase = createBaseTree(descriptor, snapshot.entries); + this.base = nextBase; + this.index = cloneTree(nextBase); + this.working = applyGitStatusToTree(previousIndex, this.working, nextBase, descriptor.docsRoot); + this.baseCommitSha = snapshot.baseCommitSha; + this.baseTreeSha = snapshot.baseTreeSha; + this.publishingToken = ''; + this.remoteStale = false; + this.revision += 1; + await this._persistAndEmit(); + }); + } + + public abortPublish(token: string) { + if (token === this.publishingToken) { + this.publishingToken = ''; + this._emit(); + } + } + + protected async _transfer(source: string, destination: string, isCopy: boolean) { + assertInScope(this.descriptor, source); + assertInScope(this.descriptor, destination); + if (!this.working.entries[source]) return Promise.reject(new Error(`${source} does not exist.`)); + const { existing, metadata } = getTransferMetadata(this.working, this.index, source, destination, isCopy); + return this._mutateWithMetadata(metadata, (tree) => { + transferWorkspaceEntry( + tree, + this._requireDescriptor(), + this.aliases, + existing, + source, + destination, + isCopy, + metadata, + ); + }); + } + + protected async _mutate(label: string, scopes: string[], apply: (tree: TreeState, metadata: ChangeMetadata) => void) { + return this._mutateWithMetadata(makeMetadata(label, scopes), (tree, metadata) => { + apply(tree, metadata); + }); + } + + protected async _mutateWithMetadata( + metadata: ChangeMetadata, + apply: (tree: TreeState, metadata: ChangeMetadata) => void, + ) { + return this._enqueue(async () => { + const before = cloneTree(this.working); + apply(this.working, metadata); + this.revision += 1; + await this._persistAndEmit(); + return this._result(getPathMappings(before, this.working)); + }); + } + + protected async _enqueue(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation, operation); + this.mutationQueue = result.then( + () => undefined, + () => undefined, + ); + return result; + } + + protected _result(pathMappings: PathMapping[] = []): MutationResult { + pathMappings.forEach(({ oldPath, newPath }) => this.aliases.set(oldPath, newPath)); + const changes = this.getWorkingChanges(); + return { + revision: this.revision, + changes, + pathMappings, + affectedDirectories: [...new Set(changes.map((change) => parentPath(change.path)).filter(Boolean))], + }; + } + + protected _requireDescriptor() { + if (!this.descriptor) throw new Error('No GitHub workspace is open.'); + return this.descriptor; + } + + protected _assertStagingUnlocked() { + if (this.publishingToken) throw new Error('Staging is locked while publishing.'); + } + + protected async _persistAndEmit() { + if (this.descriptor && this.key) { + await this.persistence.write( + this.key, + createPersistedWorkspace({ + descriptor: this.descriptor, + baseCommitSha: this.baseCommitSha, + baseTreeSha: this.baseTreeSha, + base: this.base, + index: this.index, + working: this.working, + revision: this.revision, + remoteStale: this.remoteStale, + }), + ); + } + this._emit(); + } + + protected _emit() { + this.snapshotCache = undefined; + this.listeners.forEach((listener) => { + listener(); + }); + } +} diff --git a/packages/github-workspace/src/persistence.ts b/packages/github-workspace/src/persistence.ts new file mode 100644 index 0000000..07a3343 --- /dev/null +++ b/packages/github-workspace/src/persistence.ts @@ -0,0 +1,76 @@ +import { DATABASE_NAME, DATABASE_VERSION, WORKSPACE_SCHEMA_VERSION, WORKSPACE_STORE_NAME } from './constants'; +import { cloneTree, getStagedChanges, getWorkingChanges } from './git-status'; + +import type { PersistedWorkspace, TreeState, WorkspaceDescriptor, WorkspacePersistence } from './types'; + +interface PersistedWorkspaceInput { + descriptor: WorkspaceDescriptor; + baseCommitSha: string; + baseTreeSha: string; + base: TreeState; + index: TreeState; + working: TreeState; + revision: number; + remoteStale: boolean; +} + +export const createPersistedWorkspace = (input: PersistedWorkspaceInput): PersistedWorkspace => ({ + schemaVersion: WORKSPACE_SCHEMA_VERSION, + descriptor: { ...input.descriptor }, + baseCommitSha: input.baseCommitSha, + baseTreeSha: input.baseTreeSha, + base: cloneTree(input.base), + workingChanges: getWorkingChanges(input.index, input.working), + stagedChanges: getStagedChanges(input.base, input.index), + revision: input.revision, + remoteStale: input.remoteStale, +}); + +const openDatabase = async (): Promise => + new Promise((resolve, reject) => { + const request = window.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.onerror = () => { + reject(request.error); + }; + request.onsuccess = () => { + resolve(request.result); + }; + request.onupgradeneeded = () => { + if (!request.result.objectStoreNames.contains(WORKSPACE_STORE_NAME)) { + request.result.createObjectStore(WORKSPACE_STORE_NAME); + } + }; + }); + +export class IndexedDbWorkspacePersistence implements WorkspacePersistence { + public async read(key: string): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const request = database.transaction(WORKSPACE_STORE_NAME, 'readonly').objectStore(WORKSPACE_STORE_NAME).get(key); + request.onerror = () => { + reject(request.error); + }; + request.onsuccess = () => { + resolve((request.result as PersistedWorkspace | undefined) ?? null); + }; + }).finally(() => { + database.close(); + }); + } + + public async write(key: string, value: PersistedWorkspace): Promise { + const database = await openDatabase(); + return new Promise((resolve, reject) => { + const transaction = database.transaction(WORKSPACE_STORE_NAME, 'readwrite'); + transaction.onerror = () => { + reject(transaction.error); + }; + transaction.oncomplete = () => { + resolve(); + }; + transaction.objectStore(WORKSPACE_STORE_NAME).put(value, key); + }).finally(() => { + database.close(); + }); + } +} diff --git a/packages/github-workspace/src/types.ts b/packages/github-workspace/src/types.ts new file mode 100644 index 0000000..1232376 --- /dev/null +++ b/packages/github-workspace/src/types.ts @@ -0,0 +1,130 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +export type WorkspaceStatus = 'ADDED' | 'DELETED' | 'MODIFIED' | 'UNTRACKED'; +export enum GitMode { + Directory = '040000', + File = '100644', + ExecutableFile = '100755', + SymbolicLink = '120000', + Submodule = '160000', +} +export type GitObjectType = 'blob' | 'commit' | 'tree'; +export type WorkspaceEntryKind = 'directory' | 'file'; + +export interface WorkspaceDescriptor { + owner: string; + repo: string; + branch: string; + docsRoot: string; +} + +export interface RemoteTreeEntry { + path: string; + mode: GitMode; + type: GitObjectType; + sha: string; + size?: number; +} + +export interface RemoteWorkspaceSnapshot { + baseCommitSha: string; + baseTreeSha: string; + entries: RemoteTreeEntry[]; +} + +export interface ChangeMetadata { + groupId: string; + label: string; + scopePaths: string[]; +} + +export interface WorkspaceEntry { + id: string; + path: string; + kind: WorkspaceEntryKind; + mode: GitMode; + type: GitObjectType; + sha?: string; + size?: number; + content?: string; + metadata?: ChangeMetadata; +} + +export interface WorkspaceChange { + id: string; + groupId: string; + label: string; + status: WorkspaceStatus; + path: string; + kind: WorkspaceEntryKind; + oldEntry: WorkspaceEntry | null; + newEntry: WorkspaceEntry | null; + scopePaths: string[]; +} + +export interface PathMapping { + oldPath: string; + newPath: string; + kind: WorkspaceEntryKind; +} + +export interface MutationResult { + revision: number; + changes: WorkspaceChange[]; + pathMappings: PathMapping[]; + affectedDirectories: string[]; +} + +export interface WorkspaceSnapshot { + descriptor: WorkspaceDescriptor | null; + baseCommitSha: string; + baseTreeSha: string; + revision: number; + hydrated: boolean; + remoteStale: boolean; + publishing: boolean; + entries: Record; + workingChanges: WorkspaceChange[]; + stagedChanges: WorkspaceChange[]; +} + +export interface PublishTreeEntry { + path: string; + mode: GitMode; + type: 'blob' | 'commit'; + sha: string | null; + content?: string; +} + +export interface PublishPlan { + token: string; + baseCommitSha: string; + baseTreeSha: string; + entries: PublishTreeEntry[]; +} + +export interface RebaseResult { + conflicts: string[]; + rebased: boolean; +} + +export interface TreeState { + entries: Record; + deletedMetadata: Record; +} + +export interface PersistedWorkspace { + schemaVersion: number; + descriptor: WorkspaceDescriptor; + baseCommitSha: string; + baseTreeSha: string; + base: TreeState; + workingChanges: WorkspaceChange[]; + stagedChanges: WorkspaceChange[]; + revision: number; + remoteStale: boolean; +} + +export interface WorkspacePersistence { + read: (key: string) => Promise; + write: (key: string, value: PersistedWorkspace) => Promise; +} diff --git a/packages/github-workspace/src/utils.ts b/packages/github-workspace/src/utils.ts new file mode 100644 index 0000000..dae8935 --- /dev/null +++ b/packages/github-workspace/src/utils.ts @@ -0,0 +1,21 @@ +import type { ChangeMetadata, WorkspaceDescriptor } from './types'; + +export const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); + +export const parentPath = (path: string) => path.split('/').slice(0, -1).join('/'); + +export const isSameOrDescendant = (path: string, root: string) => path === root || path.startsWith(`${root}/`); + +export const pathsOverlap = (left: string, right: string) => + isSameOrDescendant(left, right) || isSameOrDescendant(right, left); + +export const createId = (): string => window.crypto.randomUUID(); + +export const makeMetadata = (label: string, scopePaths: string[], groupId = createId()): ChangeMetadata => ({ + groupId, + label, + scopePaths, +}); + +export const getWorkspaceKey = (descriptor: WorkspaceDescriptor) => + [descriptor.owner, descriptor.repo, descriptor.branch, cleanPath(descriptor.docsRoot)].join('/'); diff --git a/packages/github-workspace/src/workspace-operations.ts b/packages/github-workspace/src/workspace-operations.ts new file mode 100644 index 0000000..f909682 --- /dev/null +++ b/packages/github-workspace/src/workspace-operations.ts @@ -0,0 +1,243 @@ +/* eslint-disable @typescript-eslint/no-dynamic-delete */ + +import { EMPTY_DIRECTORY_MARKER, WORKSPACE_SETTINGS_PATH } from './constants'; +import { + cloneEntry, + cloneMetadata, + copyPathState, + createDirectoryEntry, + getDirectoryPaths, + getStagedChanges, + getWorkingChanges, +} from './git-status'; +import { GitMode } from './types'; +import { cleanPath, createId, isSameOrDescendant, makeMetadata, parentPath } from './utils'; + +import type { ChangeMetadata, TreeState, WorkspaceDescriptor, WorkspaceEntry } from './types'; + +export const assertInScope = (descriptor: WorkspaceDescriptor | null, path: string) => { + if (!descriptor) throw new Error('No GitHub workspace is open.'); + const clean = cleanPath(path); + const segments = clean.split('/'); + if (!clean || segments.some((segment) => !segment || segment === '.' || segment === '..')) { + throw new Error('Workspace paths cannot be empty or contain traversal segments.'); + } + const root = cleanPath(descriptor.docsRoot); + if (root && !clean.startsWith(`${root}/`)) throw new Error(`${path} is outside the configured docs root.`); + if (!root && clean === WORKSPACE_SETTINGS_PATH) { + throw new Error(`${WORKSPACE_SETTINGS_PATH} is managed by the workspace and cannot be changed here.`); + } +}; + +const assertAvailable = (tree: TreeState, path: string) => { + if (tree.entries[path] || Object.keys(tree.entries).some((entryPath) => isSameOrDescendant(entryPath, path))) { + throw new Error(`${path} already exists.`); + } +}; + +const ensureParents = (tree: TreeState, descriptor: WorkspaceDescriptor, path: string, metadata?: ChangeMetadata) => { + const root = cleanPath(descriptor.docsRoot); + getDirectoryPaths(path, root).forEach((directory) => { + tree.entries[directory] ??= { ...createDirectoryEntry(directory), metadata }; + }); +}; + +const removeParentMarker = (tree: TreeState, path: string, metadata: ChangeMetadata) => { + const marker = `${parentPath(path)}/${EMPTY_DIRECTORY_MARKER}`.replace(/^\//, ''); + if (!tree.entries[marker]) return; + delete tree.entries[marker]; + tree.deletedMetadata[marker] = metadata; +}; + +const ensureEmptyDirectoryMarker = (tree: TreeState, directory: string, metadata: ChangeMetadata) => { + if (!directory || !tree.entries[directory] || tree.entries[directory].kind !== 'directory') return; + const hasChild = Object.values(tree.entries).some( + (entry) => parentPath(entry.path) === directory && entry.path !== `${directory}/${EMPTY_DIRECTORY_MARKER}`, + ); + const marker = `${directory}/${EMPTY_DIRECTORY_MARKER}`; + if (!hasChild && !tree.entries[marker]) { + tree.entries[marker] = { + id: createId(), + path: marker, + kind: 'file', + mode: GitMode.File, + type: 'blob', + content: '', + metadata, + }; + } +}; + +export const listDirectoryEntries = (tree: TreeState, path: string) => { + const directory = cleanPath(path); + return Object.values(tree.entries) + .filter((entry) => parentPath(entry.path) === directory && entry.path.split('/').at(-1) !== EMPTY_DIRECTORY_MARKER) + .map(cloneEntry) + .sort((left, right) => { + if (left.kind !== right.kind) return left.kind === 'directory' ? -1 : 1; + return left.path.localeCompare(right.path); + }); +}; + +export const resolveMovedEntryPath = (tree: TreeState, aliases: Map, path: string) => { + if (tree.entries[path]) return path; + let resolved = path; + const visited = new Set(); + while (aliases.has(resolved) && !visited.has(resolved)) { + visited.add(resolved); + resolved = aliases.get(resolved)!; + } + return resolved; +}; + +export const getWorkspaceEntry = (tree: TreeState, aliases: Map, path: string) => { + const resolved = resolveMovedEntryPath(tree, aliases, cleanPath(path)); + const entry = tree.entries[resolved]; + return entry ? cloneEntry(entry) : undefined; +}; + +export const createFileEntry = ( + tree: TreeState, + descriptor: WorkspaceDescriptor, + path: string, + content: string, + metadata: ChangeMetadata, +) => { + assertAvailable(tree, path); + ensureParents(tree, descriptor, path, metadata); + removeParentMarker(tree, path, metadata); + tree.entries[path] = { + id: createId(), + path, + kind: 'file', + mode: GitMode.File, + type: 'blob', + content, + metadata, + }; +}; + +export const createDirectory = ( + tree: TreeState, + descriptor: WorkspaceDescriptor, + path: string, + metadata: ChangeMetadata, +) => { + assertAvailable(tree, path); + ensureParents(tree, descriptor, `${path}/child`, metadata); + tree.entries[path] = { ...createDirectoryEntry(path), id: createId(), metadata }; + tree.entries[`${path}/${EMPTY_DIRECTORY_MARKER}`] = { + id: createId(), + path: `${path}/${EMPTY_DIRECTORY_MARKER}`, + kind: 'file', + mode: GitMode.File, + type: 'blob', + content: '', + metadata, + }; + removeParentMarker(tree, path, metadata); +}; + +export const writeFileEntry = (tree: TreeState, path: string, content: string, metadata: ChangeMetadata) => { + const entry = tree.entries[path]; + if (!entry || entry.kind !== 'file') throw new Error(`The file ${path} does not exist.`); + entry.content = content; + entry.metadata = metadata; +}; + +export const deleteWorkspaceEntry = (tree: TreeState, base: TreeState, path: string, metadata: ChangeMetadata) => { + const targets = Object.keys(tree.entries).filter((entryPath) => isSameOrDescendant(entryPath, path)); + if (!targets.length) throw new Error(`${path} does not exist.`); + targets.forEach((target) => { + const origin = Object.values(base.entries).find((entry) => entry.id === tree.entries[target]?.id); + delete tree.entries[target]; + tree.deletedMetadata[target] = metadata; + if (origin) tree.deletedMetadata[origin.path] = metadata; + }); + ensureEmptyDirectoryMarker(tree, parentPath(path), metadata); +}; + +export const getTransferMetadata = ( + working: TreeState, + index: TreeState, + source: string, + destination: string, + isCopy: boolean, +) => { + const existing = working.entries[source]; + if (!existing) throw new Error(`${source} does not exist.`); + const inheritedMetadata = !isCopy && existing.metadata && !index.entries[source] ? existing.metadata : undefined; + const metadata = + inheritedMetadata ?? makeMetadata(`${isCopy ? 'Copy' : 'Move'} ${source} to ${destination}`, [source, destination]); + metadata.label = inheritedMetadata + ? `${existing.kind === 'file' ? 'Create file' : 'Create folder'} ${destination}` + : metadata.label; + metadata.scopePaths = [source, destination]; + return { existing, metadata }; +}; + +export const transferWorkspaceEntry = ( + tree: TreeState, + descriptor: WorkspaceDescriptor, + aliases: Map, + existing: WorkspaceEntry, + source: string, + destination: string, + isCopy: boolean, + metadata: ChangeMetadata, +) => { + if (existing.kind === 'directory' && isSameOrDescendant(destination, source)) { + throw new Error('A directory cannot be moved or copied into itself.'); + } + assertAvailable(tree, destination); + const targets = Object.values(tree.entries).filter((entry) => isSameOrDescendant(entry.path, source)); + ensureParents(tree, descriptor, `${destination}/child`, metadata); + targets.forEach((entry) => { + const destinationPath = entry.path === source ? destination : `${destination}${entry.path.slice(source.length)}`; + tree.entries[destinationPath] = { + ...cloneEntry(entry), + id: isCopy ? createId() : entry.id, + path: destinationPath, + metadata, + }; + delete tree.deletedMetadata[destinationPath]; + if (!isCopy) { + delete tree.entries[entry.path]; + tree.deletedMetadata[entry.path] = metadata; + aliases.set(entry.path, destinationPath); + } + }); + removeParentMarker(tree, destination, metadata); + if (!isCopy) ensureEmptyDirectoryMarker(tree, parentPath(source), metadata); +}; + +export const stageChanges = (base: TreeState, index: TreeState, working: TreeState, groupIds: string[]) => { + const selected = new Set(groupIds); + getWorkingChanges(index, working) + .filter((change) => selected.has(change.groupId)) + .forEach((change) => { + copyPathState(working, index, change.path); + if (!change.newEntry && change.oldEntry) { + const origin = Object.values(base.entries).find((entry) => entry.id === change.oldEntry?.id); + if (origin) index.deletedMetadata[origin.path] = cloneMetadata(change); + } + }); +}; + +export const unstageChanges = (base: TreeState, index: TreeState, groupIds: string[]) => { + const selected = new Set(groupIds); + getStagedChanges(base, index) + .filter((change) => selected.has(change.groupId)) + .forEach((change) => { + copyPathState(base, index, change.path); + }); +}; + +export const restoreWorkingChanges = (index: TreeState, working: TreeState, groupIds: string[]) => { + const selected = new Set(groupIds); + getWorkingChanges(index, working) + .filter((change) => selected.has(change.groupId)) + .forEach((change) => { + copyPathState(index, working, change.path); + }); +}; diff --git a/packages/github-workspace/tsconfig.json b/packages/github-workspace/tsconfig.json new file mode 100644 index 0000000..9c71e5c --- /dev/null +++ b/packages/github-workspace/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM"], + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9490442..4e5a696 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,6 +92,9 @@ importers: '@emotion/styled': specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0)(@types/react@19.1.6)(react@19.1.0) + '@markdown-editor/github-workspace': + specifier: workspace:* + version: link:../packages/github-workspace '@milkdown/crepe': specifier: 7.21.2 version: 7.21.2(typescript@7.0.2) @@ -235,6 +238,12 @@ importers: specifier: ^3.101.0 version: 3.114.17 + packages/github-workspace: + devDependencies: + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: /@typescript/typescript6@6.0.2 + packages: /@antfu/install-pkg@1.1.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d14c059..31a42fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - 'packages/*' # all packages in subdirs of components/ - 'client/**' - 'crates/**' diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 12db14c..240d6b1 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -1,4 +1,11 @@ { "extends": "./tsconfig.json", - "include": ["client/**/*.ts", "client/**/*.tsx", ".eslintrc.js", "scripts/**/*.ts", "github-auth-worker/**/*.ts"] + "include": [ + "client/**/*.ts", + "client/**/*.tsx", + "packages/**/*.ts", + ".eslintrc.js", + "scripts/**/*.ts", + "github-auth-worker/**/*.ts" + ] } From 420154bca56afecfca7c0579a8a9419d57887273 Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 28 Aug 2026 11:55:12 +0800 Subject: [PATCH 08/17] feat: refactor github auth and install workflow with various other fixes --- client/src/App.scss | 4 + client/src/App.tsx | 2 + client/src/components/GitBox/CommitMsgBox.tsx | 5 +- client/src/components/GitBox/GitBox.scss | 13 +- .../components/GitBox/GitHubChangesBox.tsx | 195 ++++++++++---- client/src/components/Menu/Empty.tsx | 87 +++++- client/src/components/Menu/Menu.scss | 3 + client/src/components/Menu/Menu.tsx | 37 +-- client/src/components/OpenTab/OpenTab.tsx | 2 +- .../components/Settings/GitHubSettings.tsx | 167 +++++++++--- client/src/components/Sidebar/Sidebar.scss | 27 +- client/src/components/Sidebar/Sidebar.tsx | 42 ++- client/src/constants.ts | 7 + client/src/redux-api/github.ts | 254 ++++++++++-------- .../src/redux-feature/githubWorkspaceSlice.ts | 18 +- client/src/theme/cyber.scss | 2 +- client/src/utils/githubWorkspace.ts | 6 +- client/src/utils/githubWorkspaceRuntime.ts | 24 +- client/src/utils/hooks/githubAuthHooks.ts | 144 +++++++--- .../utils/hooks/githubInstallationHooks.ts | 62 +++++ client/src/utils/hooks/workspaceHooks.ts | 52 +++- packages/github-workspace/src/constants.ts | 8 +- packages/github-workspace/src/git-status.ts | 42 ++- packages/github-workspace/src/index.ts | 50 ++-- packages/github-workspace/src/persistence.ts | 39 ++- packages/github-workspace/src/types.ts | 10 + packages/github-workspace/src/utils.ts | 5 +- .../src/workspace-operations.ts | 90 +++++-- 28 files changed, 992 insertions(+), 405 deletions(-) create mode 100644 client/src/utils/hooks/githubInstallationHooks.ts diff --git a/client/src/App.scss b/client/src/App.scss index 042bf0d..372d126 100644 --- a/client/src/App.scss +++ b/client/src/App.scss @@ -97,3 +97,7 @@ body { .p-confirm-dialog-message { margin: 0; } + +.p-confirm-dialog-message { + margin-top: 0.5rem; +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 4e85ae0..7fcc2cc 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -13,6 +13,7 @@ import { SplitBar } from './components/SplitBar'; import { APP_VERSION } from './constants'; import { selectWorkspaceMode } from './redux-feature/githubWorkspaceSlice'; import { selectMenuCollapse } from './redux-feature/globalOptsSlice'; +import { useGitHubInstallationGuard } from './utils/hooks/githubInstallationHooks'; import { useCheckServer, useWarnUnsavedOnUnload } from './utils/hooks/reduxHooks'; import { useGitHubWorkspaceSync } from './utils/hooks/workspaceHooks'; @@ -22,6 +23,7 @@ export const App: FC = () => { useWarnUnsavedOnUnload(); const workspaceMode = useSelector(selectWorkspaceMode); + useGitHubInstallationGuard(); useGitHubWorkspaceSync(); const { isLoading, isError, isSuccess, data: serverCheckRes } = useCheckServer(workspaceMode === 'local'); const menuCollapse = useSelector(selectMenuCollapse); diff --git a/client/src/components/GitBox/CommitMsgBox.tsx b/client/src/components/GitBox/CommitMsgBox.tsx index dcec910..c838f71 100644 --- a/client/src/components/GitBox/CommitMsgBox.tsx +++ b/client/src/components/GitBox/CommitMsgBox.tsx @@ -3,11 +3,12 @@ import { InputTextarea } from 'primereact/inputtextarea'; import { FC, useState } from 'react'; interface CommitMsgBoxProps { + disabled?: boolean; onCommitMsgTitleChange: (commitMsgTitle: string) => void; onCommitMsgBodyChange: (commitMsgBody: string) => void; } -export const CommitMsgBox: FC = ({ onCommitMsgTitleChange, onCommitMsgBodyChange }) => { +export const CommitMsgBox: FC = ({ disabled, onCommitMsgTitleChange, onCommitMsgBodyChange }) => { const [commitMsgTitle, setCommitMsgTitle] = useState(''); const [commitMsgBody, setCommitMsgBody] = useState(''); @@ -17,6 +18,7 @@ export const CommitMsgBox: FC = ({ onCommitMsgTitleChange, on { setCommitMsgTitle(event.target.value); onCommitMsgTitleChange(event.target.value); @@ -27,6 +29,7 @@ export const CommitMsgBox: FC = ({ onCommitMsgTitleChange, on
Body
{ setCommitMsgBody(event.target.value); onCommitMsgBodyChange(event.target.value); diff --git a/client/src/components/GitBox/GitBox.scss b/client/src/components/GitBox/GitBox.scss index 2c07d2e..224055b 100644 --- a/client/src/components/GitBox/GitBox.scss +++ b/client/src/components/GitBox/GitBox.scss @@ -51,7 +51,7 @@ justify-content: flex-end; align-items: center; border-bottom: 1px solid $panelBorderColor; - .icon-wrapper { + .icon-wrapper + .icon-wrapper { margin-left: 10px; } } @@ -187,17 +187,6 @@ } .github-changes-box { - .op-box > button { - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - padding: 0.4rem; - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } - } .no-git-service { height: auto; padding: 0.5rem; diff --git a/client/src/components/GitBox/GitHubChangesBox.tsx b/client/src/components/GitBox/GitHubChangesBox.tsx index 95d82fd..2d1cc18 100644 --- a/client/src/components/GitBox/GitHubChangesBox.tsx +++ b/client/src/components/GitBox/GitHubChangesBox.tsx @@ -1,6 +1,9 @@ import AddIcon from '@mui/icons-material/AddOutlined'; +import FileOpenIcon from '@mui/icons-material/FileOpenOutlined'; import RemoveIcon from '@mui/icons-material/RemoveOutlined'; import UndoIcon from '@mui/icons-material/UndoOutlined'; +import { Button } from 'primereact/button'; +import { Dialog } from 'primereact/dialog'; import { FC, useState, type ElementType } from 'react'; import { useSelector } from 'react-redux'; @@ -11,23 +14,23 @@ import { Icon } from '@/components/Icon/Icon'; import { githubApi, usePublishGitHubWorkspaceMutation } from '@/redux-api/github'; import { selectGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; import { store } from '@/store'; -import { getVisibleLogicalPath } from '@/utils/githubWorkspace'; +import { getVisibleGitHubWorkspaceChanges, getVisibleLogicalPath } from '@/utils/githubWorkspace'; import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; -import { useDeleteTab, useRenameTabs } from '@/utils/hooks/reduxHooks'; +import { useCurPath } from '@/utils/hooks/docHooks'; +import { useDeleteTab, useRenameTabs, useSaveDoc } from '@/utils/hooks/reduxHooks'; import Toast from '@/utils/Toast'; -import { confirm } from '@/utils/utils'; +import { confirm, normalizePath } from '@/utils/utils'; import './GitBox.scss'; -const visibleChanges = (changes: WorkspaceChange[]) => changes.filter((change) => !change.path.endsWith('/.gitkeep')); - const ChangeList: FC<{ changes: WorkspaceChange[]; actionIcon: ElementType; actionTitle: string; onAction: (id: string) => void; + onOpen?: (change: WorkspaceChange) => void; onRevert?: (id: string) => void; -}> = ({ changes, actionIcon, actionTitle, onAction, onRevert }) => +}> = ({ changes, actionIcon, actionTitle, onAction, onOpen, onRevert }) => changes.length ? (
    {changes.map((change, index) => ( @@ -36,6 +39,18 @@ const ChangeList: FC<{ {change.path}
    + {onOpen && change.status !== 'DELETED' && change.kind === 'file' && change.path.endsWith('.md') && ( + { + onOpen(change); + }} + /> + )} {onRevert && ( { const config = useSelector(selectGitHubWorkspaceConfig); const workspace = useGitHubWorkspaceSnapshot(); + const { navigate, curPath } = useCurPath(); + const saveDoc = useSaveDoc(); const renameTabs = useRenameTabs(); const deleteTabs = useDeleteTab(); + const [publishDialogVisible, setPublishDialogVisible] = useState(false); + const [publishTitle, setPublishTitle] = useState(''); + const [publishBody, setPublishBody] = useState(''); const [publishing, setPublishing] = useState(false); const [publish] = usePublishGitHubWorkspaceMutation(); - const workingChanges = visibleChanges(workspace.workingChanges); - const stagedChanges = visibleChanges(workspace.stagedChanges); + const workingChanges = getVisibleGitHubWorkspaceChanges(workspace.workingChanges); + const stagedChanges = getVisibleGitHubWorkspaceChanges(workspace.stagedChanges); const applyPathMappings = (result: MutationResult) => { const operations = result.pathMappings @@ -107,31 +127,27 @@ export const GitHubChangesBox: FC = () => { if (paths.length) await deleteTabs(paths, { force: true }); }; + const openFile = (repositoryPath: string) => { + const logicalPath = getVisibleLogicalPath(config, repositoryPath); + if (normalizePath(curPath) === logicalPath) return; + void saveDoc(); + void navigate(`/article/${logicalPath}`); + }; + const publishChanges = async () => { - if (!stagedChanges.length || workspace.remoteStale) return; - let title = ''; - let body = ''; - const accepted = await confirm({ - message: ( - (title = commitTitle)} - onCommitMsgBodyChange={(commitBody) => (body = commitBody)} - /> - ), - acceptLabel: 'Publish', - }); - if (!accepted) return; - if (!title.trim()) { + if (publishing || !stagedChanges.length || workspace.remoteStale) return; + if (!publishTitle.trim()) { Toast.warn('Commit title cannot be blank'); return; } let token = ''; try { setPublishing(true); - const plan = await githubWorkspaceStore.beginPublish(); + const plan = githubWorkspaceStore.beginPublish(); token = plan.token; - const snapshot = await publish({ config, plan, title: title.trim(), body }).unwrap(); + const snapshot = await publish({ config, plan, title: publishTitle.trim(), body: publishBody }).unwrap(); await githubWorkspaceStore.completePublish(snapshot, token); + setPublishDialogVisible(false); Toast('Published to GitHub'); } catch (error) { if (token) githubWorkspaceStore.abortPublish(token); @@ -172,10 +188,11 @@ export const GitHubChangesBox: FC = () => { } }; - const discardAll = async () => { - if (!(await confirm({ message: 'Discard every local GitHub workspace change?' }))) return; - const discardedChanges = [...workspace.workingChanges, ...workspace.stagedChanges]; - const result = await githubWorkspaceStore.discardAll(); + const discardWorkingChanges = async () => { + if (!(await confirm({ message: 'Discard every working-space change?' }))) return; + const discardedChanges = workspace.workingChanges; + const groupIds = [...new Set(discardedChanges.map((change) => change.groupId))]; + const result = await githubWorkspaceStore.restoreWorking(groupIds); applyPathMappings(result); await closeDiscardedUntrackedTabs(discardedChanges); }; @@ -183,57 +200,125 @@ export const GitHubChangesBox: FC = () => { return (
    - - - - + void refreshAndRebase()} + /> + { + setPublishTitle(''); + setPublishBody(''); + setPublishDialogVisible(true); + }} + />
    + { + if (!publishing) setPublishDialogVisible(false); + }} + footer={ +
    +
    + } + > + {publishDialogVisible && ( + + )} +
    {workspace.remoteStale && (
    The remote branch changed. Refresh and rebase first.
    )}
    Staged
    +
    + { + const groupIds = [...new Set(workspace.stagedChanges.map((change) => change.groupId))]; + void githubWorkspaceStore.unstage(groupIds); + }} + /> +
    void githubWorkspaceStore.unstage([id])} + onOpen={(change) => { + openFile(change.path); + }} />
    Working Space
    +
    + void discardWorkingChanges()} + /> + void githubWorkspaceStore.stageAll()} + /> +
    void githubWorkspaceStore.stage([id])} + onOpen={(change) => { + openFile(change.path); + }} onRevert={(id) => { const discardedChanges = workspace.workingChanges.filter((change) => change.groupId === id); void githubWorkspaceStore.restoreWorking([id]).then(async (result) => { diff --git a/client/src/components/Menu/Empty.tsx b/client/src/components/Menu/Empty.tsx index 61b5b9c..923c22b 100644 --- a/client/src/components/Menu/Empty.tsx +++ b/client/src/components/Menu/Empty.tsx @@ -1,12 +1,27 @@ import { Button } from 'primereact/button'; +import { ProgressSpinner } from 'primereact/progressspinner'; import { FC, useState } from 'react'; +import { useSelector } from 'react-redux'; import { FolderSelectorModal } from '@/components/FolderSelector/FolderSelector'; -import { useUpdateSettingsMutation } from '@/redux-api/settings'; +import { useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings'; +import { selectGitHubWorkspaceConfig, selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; +import { startGitHubInstall, startGitHubLogin, useGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; import Toast from '@/utils/Toast'; -export const Empty: FC = () => { +interface EmptyProps { + hasWorkspaceError?: boolean; + onCreateFirstDoc: () => void; +} + +export const Empty: FC = ({ hasWorkspaceError = false, onCreateFirstDoc }) => { const [showFolderSelector, setShowFolderSelector] = useState(false); + const workspaceMode = useSelector(selectWorkspaceMode); + const githubWorkspaceConfig = useSelector(selectGitHubWorkspaceConfig); + const githubAccessToken = useGitHubAccessToken(); + const { data: settings, isFetching: isSettingsFetching } = useGetSettingsQuery(undefined, { + skip: workspaceMode === 'github', + }); const [updateSettings] = useUpdateSettingsMutation(); const handleModalHidden = () => { @@ -26,17 +41,67 @@ export const Empty: FC = () => { } }; + const renderAction = () => { + if (workspaceMode === 'github') { + if (!githubAccessToken) { + return ( + + ); + } + + if (hasWorkspaceError) { + return ( + + ); + } + + if (!githubWorkspaceConfig.owner || !githubWorkspaceConfig.repo || !githubWorkspaceConfig.branch) { + return
    Choose a GitHub workspace in Settings.
    ; + } + + return ( + + ); + } + + if (isSettingsFetching) { + return ; + } + + if (!settings?.docRootPath) { + return ( + + ); + } + + if (hasWorkspaceError) { + return
    Oops, something went wrong while loading the workspace.
    ; + } + + return ( + + ); + }; + return (
    - + {renderAction()}
    ); diff --git a/client/src/components/Menu/Menu.scss b/client/src/components/Menu/Menu.scss index 13e0ff4..080a325 100644 --- a/client/src/components/Menu/Menu.scss +++ b/client/src/components/Menu/Menu.scss @@ -120,5 +120,8 @@ flex-direction: column; font-size: 14px; text-align: center; + .pi { + margin-right: 0.5rem; + } } } diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx index 7b43d61..d799b9b 100644 --- a/client/src/components/Menu/Menu.tsx +++ b/client/src/components/Menu/Menu.tsx @@ -2,7 +2,6 @@ import { ContextMenu } from 'primereact/contextmenu'; import { MenuItem as PrimeMenuItem } from 'primereact/menuitem'; import { ProgressSpinner } from 'primereact/progressspinner'; import { ScrollPanel } from 'primereact/scrollpanel'; -import { Tooltip } from 'primereact/tooltip'; import { FC, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { UncontrolledTreeEnvironment, @@ -22,10 +21,7 @@ import { createRenderItem, renderDragBetweenLine, renderItemArrow } from './rend import { Shortcut } from './Shortcut'; import { TreeDataCtx, TreeRefCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type'; -import { useGetSettingsQuery } from '@/redux-api/settings'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; -import { selectGitHubWorkspaceConfig, selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; -import { selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import { selectOperationMenu, updateSelectedItems } from '@/redux-feature/operationMenuSlice'; import { useWorkspaceRootItemsQuery } from '@/utils/hooks/workspaceHooks'; import { normalizePath, scrollToView, waitAndCheck, denormalizePath } from '@/utils/utils'; @@ -39,16 +35,12 @@ export const Menu: FC = () => { const cm = useRef(null); const [isEnterMenu, setIsEnterMenu] = useState(false); - const workspaceMode = useSelector(selectWorkspaceMode); - const githubWorkspaceConfig = useSelector(selectGitHubWorkspaceConfig); - const { data: docRootItems = [], isFetching, isSuccess, isError, error } = useWorkspaceRootItemsQuery(); + const [isCreatingFirstDoc, setIsCreatingFirstDoc] = useState(false); + const { data: docRootItems = [], isFetching, isSuccess, isError } = useWorkspaceRootItemsQuery(); const updateSubDocItems = useUpdateSubDocItems(); const { contentIdent: contentPath } = useSelector(selectCurDoc); const { copyCutPaths } = useSelector(selectOperationMenu); - const serverStatus = useSelector(selectServerStatus); - const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' }); - const dispatch = useDispatch(); const renderItem = useMemo(() => createRenderItem(), []); @@ -88,6 +80,11 @@ export const Menu: FC = () => { }, [docRootItems]); const treeDataProvider = useMemo(() => new StaticTreeDataProvider(renderData), [renderData]); + const createFirstDoc = () => { + setIsCreatingFirstDoc(true); + void createNewDocItem(renderData.root, false, treeDataProvider, renderData); + }; + const selectedItemPathKeys = useMemo(() => { const selectedDocPath = denormalizePath(contentPath); return selectedDocPath.reduce((pathKeys, pathItem, idx) => { @@ -208,10 +205,8 @@ export const Menu: FC = () => { let content: ReactNode = <>; if (isSuccess) { - if (workspaceMode === 'github' && !githubWorkspaceConfig.owner) { - content =
    Choose a GitHub workspace in Settings.
    ; - } else if (workspaceMode === 'local' && !settings?.docRootPath) { - content = ; + if (docRootItems.length === 0 && !isCreatingFirstDoc) { + content = ; } else { content = (
    @@ -255,19 +250,7 @@ export const Menu: FC = () => { } else if (isFetching) { content = ; } else if (isError) { - if (serverStatus === ServerStatus.RUNNING && settings?.docRootPath) { - content = ( -
    - -
    - Ops, something went wrong -
    -
    - ); - } else { - // should install the server to select workspace - content = ; - } + content = ; } return ( diff --git a/client/src/components/OpenTab/OpenTab.tsx b/client/src/components/OpenTab/OpenTab.tsx index 95bc17c..7c6a9d9 100644 --- a/client/src/components/OpenTab/OpenTab.tsx +++ b/client/src/components/OpenTab/OpenTab.tsx @@ -66,7 +66,7 @@ export default function OpenTab() { const activeIdent = curTabs.find((t) => t.active)?.ident; const closeSavedTabs = () => { - const savedTabs = curTabs.filter((t) => !draftKeys.includes(getDraftKey(settings?.docRootPath, t.ident))); + const savedTabs = curTabs.filter((t) => !draftKeys.includes(getDraftKey(workspaceKey, t.ident))); void deleteTab(savedTabs.map((t) => t.ident)); }; diff --git a/client/src/components/Settings/GitHubSettings.tsx b/client/src/components/Settings/GitHubSettings.tsx index 1de8c5c..a9f561d 100644 --- a/client/src/components/Settings/GitHubSettings.tsx +++ b/client/src/components/Settings/GitHubSettings.tsx @@ -1,5 +1,6 @@ import { Button } from 'primereact/button'; import { Checkbox } from 'primereact/checkbox'; +import { Chips } from 'primereact/chips'; import { Dropdown } from 'primereact/dropdown'; import { InputText } from 'primereact/inputtext'; import { FC, useEffect, useState } from 'react'; @@ -13,26 +14,28 @@ import { useListGitHubBranchesQuery, useListGitHubRepositoriesQuery, } from '@/redux-api/github'; +import { WORKSPACE_SETTINGS_PATH } from '@/constants'; import { updateTabs } from '@/redux-feature/curDocSlice'; import { emptyConfig, selectGithubWorkspace, setGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc } from '@/utils/hooks/reduxHooks'; import Toast from '@/utils/Toast'; +import { confirm } from '@/utils/utils'; export const GitHubSettings: FC = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const saveDoc = useSaveDoc(); const workspace = useSelector(selectGithubWorkspace); - const { githubLogin, isGitHubLoginLoading, startGitHubLogin } = useGitHubLogin(); + const { githubLogin, isGitHubLoginLoading, startGitHubInstall, startGitHubLogin } = useGitHubLogin(); const { data: repositories = [], error: repositoriesError, isFetching: repositoriesLoading, } = useListGitHubRepositoriesQuery(undefined, { skip: !githubLogin }); - const [selectedRepository, setSelectedRepository] = useState( - workspace.config.owner && workspace.config.repo ? `${workspace.config.owner}/${workspace.config.repo}` : '', - ); + const savedRepository = + workspace.config.owner && workspace.config.repo ? `${workspace.config.owner}/${workspace.config.repo}` : ''; + const [selectedRepository, setSelectedRepository] = useState(savedRepository); const repository = repositories.find((item) => item.fullName === selectedRepository); const { data: branches = [], isFetching: branchesLoading } = useListGitHubBranchesQuery( repository ? { owner: repository.owner, repo: repository.name } : { owner: '', repo: '' }, @@ -40,7 +43,14 @@ export const GitHubSettings: FC = () => { ); const [branch, setBranch] = useState(workspace.config.branch); const [docsRoot, setDocsRoot] = useState(workspace.config.docsRoot); - const { data: repositorySettings } = useGetGitHubWorkspaceSettingsQuery( + const [ignoreDirs, setIgnoreDirs] = useState(workspace.config.ignoreDirs); + const { + currentData: repositorySettings, + error: repositorySettingsError, + isFetching: repositorySettingsLoading, + isSuccess: repositorySettingsLoaded, + refetch: refetchRepositorySettings, + } = useGetGitHubWorkspaceSettingsQuery( repository && branch ? { owner: repository.owner, repo: repository.name, branch } : { owner: '', repo: '', branch: '' }, @@ -53,13 +63,50 @@ export const GitHubSettings: FC = () => { const [initializeWorkspace, { isLoading: initializing }] = useInitializeGitHubWorkspaceMutation(); useEffect(() => { - if (repository && !branches.includes(branch)) setBranch(repository.defaultBranch); - }, [branch, branches, repository]); + setSelectedRepository(savedRepository); + setBranch(workspace.config.branch); + setDocsRoot(workspace.config.docsRoot); + setIgnoreDirs(workspace.config.ignoreDirs); + }, [savedRepository, workspace.config.branch, workspace.config.docsRoot, workspace.config.ignoreDirs]); useEffect(() => { - if (repositorySettings) setDocsRoot(repositorySettings.docsRoot); + if (selectedRepository === savedRepository && repository && !branchesLoading && !branches.includes(branch)) { + setBranch(repository.defaultBranch); + } + }, [branch, branches, branchesLoading, repository, savedRepository, selectedRepository]); + + useEffect(() => { + if (repositorySettings) { + setDocsRoot(repositorySettings.docsRoot); + setIgnoreDirs(repositorySettings.ignoreDirs); + } }, [repositorySettings]); + const repositoryOptions = repositories.map((item) => ({ label: item.fullName, value: item.fullName })); + if (selectedRepository && !repositoryOptions.some((option) => option.value === selectedRepository)) { + repositoryOptions.unshift({ label: selectedRepository, value: selectedRepository }); + } + const branchOptions = branches.map((item) => ({ label: item, value: item })); + if (branch && !branchOptions.some((option) => option.value === branch)) { + branchOptions.unshift({ label: branch, value: branch }); + } + const normalizedDocsRoot = docsRoot.replace(/^\/+|\/+$/g, ''); + const workspaceSettingsChanged = Boolean( + repositorySettings && + (repositorySettings.docsRoot !== normalizedDocsRoot || + repositorySettings.ignoreDirs.length !== ignoreDirs.length || + repositorySettings.ignoreDirs.some((directory, index) => directory !== ignoreDirs[index])), + ); + + const getSelectedConfig = () => ({ + ...emptyConfig, + owner: repository?.owner ?? '', + repo: repository?.name ?? '', + branch, + docsRoot: normalizedDocsRoot, + ignoreDirs, + }); + if (isGitHubLoginLoading) { return (
    @@ -75,34 +122,54 @@ export const GitHubSettings: FC = () => { return (
    -
    ); } - const selectWorkspace = async (initialize: boolean) => { - if (!repository || !branch) return; + const selectWorkspace = async () => { + if (!repository || !branch || !repositorySettingsLoaded) return; try { - const config = { - ...emptyConfig, - owner: repository.owner, - repo: repository.name, - branch, - docsRoot: docsRoot.replace(/^\/+|\/+$/g, ''), - }; - if (initialize) { + const config = getSelectedConfig(); + if (!repositorySettings) { await initializeWorkspace(config).unwrap(); } await saveDoc(); dispatch(updateTabs([])); void navigate('/purePage'); dispatch(setGitHubWorkspaceConfig(config)); - Toast(initialize ? 'GitHub workspace initialized' : 'GitHub workspace selected'); + Toast('GitHub workspace ready'); } catch (error) { Toast.error((error as { message?: string }).message ?? 'Failed to select GitHub workspace'); } }; + const updateWorkspaceSettings = async () => { + if (!repository || !branch || !repositorySettings || !workspaceSettingsChanged) return; + const confirmed = await confirm({ + header: 'Update GitHub workspace settings?', + message: `This will create a commit that updates ${WORKSPACE_SETTINGS_PATH} in the selected branch.`, + acceptLabel: 'Update settings', + }); + if (!confirmed) return; + try { + await initializeWorkspace(getSelectedConfig()).unwrap(); + await refetchRepositorySettings(); + Toast('GitHub workspace settings updated'); + } catch (error) { + Toast.error((error as { message?: string }).message ?? 'Failed to update GitHub workspace settings'); + } + }; + const createNewRepository = async () => { if (!newRepoName.trim()) return; try { @@ -134,12 +201,13 @@ export const GitHubSettings: FC = () => { Repositories appear after this GitHub App is installed on them with read and write Contents permission. +
    ({ label: item.fullName, value: item.fullName }))} + options={repositoryOptions} loading={repositoriesLoading} filter placeholder="Select a writable repository" @@ -147,15 +215,19 @@ export const GitHubSettings: FC = () => { setSelectedRepository(event.value as string); const selected = repositories.find((item) => item.fullName === event.value); setBranch(selected?.defaultBranch ?? ''); + setDocsRoot(emptyConfig.docsRoot); + setIgnoreDirs([...emptyConfig.ignoreDirs]); }} /> ({ label: item, value: item }))} + options={branchOptions} loading={branchesLoading} placeholder="Select branch" onChange={(event) => { setBranch(event.value as string); + setDocsRoot(emptyConfig.docsRoot); + setIgnoreDirs([...emptyConfig.ignoreDirs]); }} /> { setDocsRoot(event.target.value); }} /> + + { + setIgnoreDirs(event.value ?? []); + }} + /> + {repositorySettingsLoading && Checking workspace settings…} + {repositorySettingsLoaded && repositorySettings === null && ( + + Workspace settings will be created automatically when you use this workspace. + + )} + {repositorySettingsError && 'message' in repositorySettingsError && ( + {repositorySettingsError.message} + )} + {workspaceSettingsChanged && ( + + Confirm the workspace settings update before using this workspace. + + )}
    + {repositorySettings && ( +
    diff --git a/client/src/components/Sidebar/Sidebar.scss b/client/src/components/Sidebar/Sidebar.scss index 7940ff5..561efb0 100644 --- a/client/src/components/Sidebar/Sidebar.scss +++ b/client/src/components/Sidebar/Sidebar.scss @@ -7,9 +7,34 @@ border: 1px solid transparent; border-right-color: $panelBorderColor; padding: 18px 0 0 0; - .icon-wrapper { + > .icon-wrapper, + .sidebar-git-control { margin-bottom: 20px; } + .sidebar-git-control { + position: relative; + width: 100%; + .icon-wrapper { + margin-bottom: 0; + } + } + .git-change-badge { + position: absolute; + top: -8px; + left: calc(50% + 5px); + z-index: 2; + min-width: 16px; + height: 16px; + padding: 0 4px; + border-radius: 8px; + background: #007acc; + color: #fff; + font-size: 10px; + font-weight: 700; + line-height: 16px; + text-align: center; + pointer-events: none; + } } .sidebar-overlay { diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 9d39a3e..c21b7ec 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -2,6 +2,7 @@ import GitFlow from '@mui/icons-material/CommitOutlined'; import MenuClose from '@mui/icons-material/MenuOpenOutlined'; import MenuOpen from '@mui/icons-material/MenuOutlined'; import SettingIcon from '@mui/icons-material/SettingsOutlined'; +import { Badge } from 'primereact/badge'; import { Button } from 'primereact/button'; import { Dialog } from 'primereact/dialog'; import { OverlayPanel } from 'primereact/overlaypanel'; @@ -13,12 +14,15 @@ import { GitBox } from '@/components/GitBox/GitBox'; import { GitHubChangesBox } from '@/components/GitBox/GitHubChangesBox'; import { Icon } from '@/components/Icon/Icon'; import { SettingsBox } from '@/components/Settings/Settings'; +import { useGetGitStatusQuery } from '@/redux-api/git'; import { Settings, useGetSettingsQuery, useUpdateSettingsMutation } from '@/redux-api/settings'; import { updateTabs } from '@/redux-feature/curDocSlice'; import { clearAllDrafts } from '@/redux-feature/draftsSlice'; import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { updateGlobalOpts, selectGlobalOpts, selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import ErrorBoundary from '@/utils/ErrorBoundary/ErrorBoundary'; +import { getVisibleGitHubWorkspaceChanges } from '@/utils/githubWorkspace'; +import { useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import Toast from '@/utils/Toast'; import { isEqual, nextTick } from '@/utils/utils'; @@ -30,12 +34,23 @@ export const Sidebar: FC = () => { const [newSettings, setNewSettings] = useState({}); const workspaceMode = useSelector(selectWorkspaceMode); - const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' }); - const [updateSettings] = useUpdateSettingsMutation(); - + const githubWorkspace = useGitHubWorkspaceSnapshot(); const { menuCollapse } = useSelector(selectGlobalOpts); const serverStatus = useSelector(selectServerStatus); const isServerNotConnected = serverStatus === ServerStatus.CANNOT_CONNECT; + const { data: settings } = useGetSettingsQuery(undefined, { skip: workspaceMode === 'github' }); + const { data: localGitStatus } = useGetGitStatusQuery(undefined, { + skip: workspaceMode === 'github' || isServerNotConnected, + }); + const [updateSettings] = useUpdateSettingsMutation(); + const githubChangeCount = + workspaceMode === 'github' + ? getVisibleGitHubWorkspaceChanges([...githubWorkspace.workingChanges, ...githubWorkspace.stagedChanges]).length + : 0; + const localChangeCount = isServerNotConnected + ? 0 + : [...(localGitStatus?.workspace ?? []), ...(localGitStatus?.staged ?? [])].length; + const gitChangeCount = workspaceMode === 'github' ? githubChangeCount : localChangeCount; const dispatch = useDispatch(); const navigate = useNavigate(); @@ -108,15 +123,18 @@ export const Sidebar: FC = () => { ); }} /> - +
    + + {gitChangeCount > 0 && } +
    {workspaceMode === 'github' ? : } diff --git a/client/src/constants.ts b/client/src/constants.ts index 491f827..f9c42f6 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -7,3 +7,10 @@ export const SERVER_PORT = __SERVER_PORT__; export const ONLINE_MODE = Boolean(SERVER_PORT); export const SERVER_BASE_URL = ONLINE_MODE ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api'; + +export const DEFAULT_IGNORE_DIRS = ['imgs', 'node_modules', 'dist']; + +export const GITHUB_WORKSPACE_DATABASE_NAME = 'markdown-editor-github-workspaces'; +export const GITHUB_WORKSPACE_STORE_NAME = 'workspaces-v1'; +export const GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER = '.gitkeep'; +export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json'; diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts index c306036..2707bcb 100644 --- a/client/src/redux-api/github.ts +++ b/client/src/redux-api/github.ts @@ -2,16 +2,19 @@ /* eslint-disable @typescript-eslint/naming-convention */ /* eslint-disable @typescript-eslint/no-magic-numbers */ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ +import { GitMode } from '@markdown-editor/github-workspace'; import { Octokit } from '@octokit/rest'; import { createApi, fakeBaseQuery } from '@reduxjs/toolkit/query/react'; +import type { GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; import type { - GitMode, PublishPlan, + PublishTreeEntry, RemoteWorkspaceSnapshot, WorkspaceDescriptor, } from '@markdown-editor/github-workspace'; +import { DEFAULT_IGNORE_DIRS, GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, WORKSPACE_SETTINGS_PATH } from '@/constants'; import { getGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; export interface GitHubRepositoryOption { @@ -38,6 +41,12 @@ export interface PublishGitHubWorkspacePayload { body: string; } +export interface GitHubWorkspaceSettings { + docsRoot: string; + ignoreDirs: string[]; + schemaVersion: number; +} + let octokitToken = ''; let octokitClient: Octokit | null = null; @@ -94,65 +103,110 @@ const getSnapshot = async ( return { baseCommitSha: commitSha, baseTreeSha: treeSha, entries }; }; +interface CommitTreeOptions { + baseCommitSha: string; + baseTreeSha: string; + config: Pick; + entries: PublishTreeEntry[]; + message: string; +} + +/** + * Writes a set of file changes as one commit and advances the configured branch. + * Entry content creates a new blob, an existing SHA is reused, and a null SHA without content deletes the path. + */ +const commitTreeToBranch = async ({ baseCommitSha, baseTreeSha, config, entries, message }: CommitTreeOptions) => { + const octokit = getOctokit(); + // Materialize new content as blobs while preserving existing blobs and deletion markers. + const treeEntries = await Promise.all( + entries.map(async (entry) => { + if (entry.content === undefined) { + if (entry.sha === null) return { path: entry.path, mode: entry.mode, type: entry.type, sha: null }; + if (!entry.sha) throw new Error(`No content is available for ${entry.path}.`); + return { path: entry.path, mode: entry.mode, type: entry.type, sha: entry.sha }; + } + const blob = await octokit.request('POST /repos/{owner}/{repo}/git/blobs', { + owner: config.owner, + repo: config.repo, + content: entry.content, + encoding: 'utf-8', + }); + return { path: entry.path, mode: entry.mode, type: entry.type, sha: blob.data.sha }; + }), + ); + // Apply the resolved entries to the base tree, then create a commit on top of the expected parent. + const tree = await octokit.request('POST /repos/{owner}/{repo}/git/trees', { + owner: config.owner, + repo: config.repo, + base_tree: baseTreeSha, + tree: treeEntries, + }); + const commit = await octokit.request('POST /repos/{owner}/{repo}/git/commits', { + owner: config.owner, + repo: config.repo, + message, + tree: tree.data.sha, + parents: [baseCommitSha], + }); + // Move the branch only when GitHub can perform a non-forced update. + await octokit.request('PATCH /repos/{owner}/{repo}/git/refs/{ref}', { + owner: config.owner, + repo: config.repo, + ref: `heads/${config.branch}`, + sha: commit.data.sha, + force: false, + }); +}; + const initializeWorkspace = async ( - config: Pick, + config: Pick, ): Promise => { const octokit = getOctokit(); const docsRoot = config.docsRoot.replace(/^\/+|\/+$/g, ''); - const settingsContent = `${JSON.stringify({ schemaVersion: 1, docsRoot }, null, 2)}\n`; + const settingsContent = `${JSON.stringify({ schemaVersion: 1, docsRoot, ignoreDirs: config.ignoreDirs }, null, 2)}\n`; let snapshot: GitHubWorkspaceSnapshot; + let isInitializing: boolean; try { snapshot = await getSnapshot(config); + isInitializing = !snapshot.entries.some((entry) => entry.path === WORKSPACE_SETTINGS_PATH); } catch (error) { const status = (error as { status?: number }).status; if (status !== 404 && status !== 409) throw error; + isInitializing = true; await octokit.request('PUT /repos/{owner}/{repo}/contents/{path}', { owner: config.owner, repo: config.repo, - path: '.workspace-settings.json', + path: WORKSPACE_SETTINGS_PATH, message: 'Initialize Markdown Editor workspace', content: window.btoa(settingsContent), }); snapshot = await getSnapshot(config); } - const settingsBlob = await octokit.request('POST /repos/{owner}/{repo}/git/blobs', { - owner: config.owner, - repo: config.repo, - content: settingsContent, - encoding: 'utf-8', - }); - const keepBlob = docsRoot - ? await octokit.request('POST /repos/{owner}/{repo}/git/blobs', { - owner: config.owner, - repo: config.repo, - content: '', - encoding: 'utf-8', - }) - : null; - const tree = await octokit.request('POST /repos/{owner}/{repo}/git/trees', { - owner: config.owner, - repo: config.repo, - base_tree: snapshot.baseTreeSha, - tree: [ - { path: '.workspace-settings.json', mode: '100644', type: 'blob', sha: settingsBlob.data.sha }, - ...(keepBlob - ? [{ path: `${docsRoot}/.gitkeep`, mode: '100644' as const, type: 'blob' as const, sha: keepBlob.data.sha }] + await commitTreeToBranch({ + config, + baseCommitSha: snapshot.baseCommitSha, + baseTreeSha: snapshot.baseTreeSha, + entries: [ + { + path: WORKSPACE_SETTINGS_PATH, + mode: GitMode.File, + type: 'blob', + sha: null, + content: settingsContent, + }, + ...(docsRoot + ? [ + { + path: `${docsRoot}/${GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER}`, + mode: GitMode.File, + type: 'blob' as const, + sha: null, + content: '', + }, + ] : []), ], - }); - const commit = await octokit.request('POST /repos/{owner}/{repo}/git/commits', { - owner: config.owner, - repo: config.repo, - message: 'Initialize Markdown Editor workspace', - tree: tree.data.sha, - parents: [snapshot.baseCommitSha], - }); - await octokit.request('PATCH /repos/{owner}/{repo}/git/refs/{ref}', { - owner: config.owner, - repo: config.repo, - ref: `heads/${config.branch}`, - sha: commit.data.sha, - force: false, + message: `${isInitializing ? 'Initialize' : 'Update'} Markdown Editor workspace`, }); return getSnapshot(config); }; @@ -163,6 +217,31 @@ const decodeBase64Utf8 = (content: string) => { return new TextDecoder().decode(bytes); }; +export const listAccessibleGitHubRepositories = async (): Promise => { + const octokit = getOctokit(); + const installations = await octokit.paginate('GET /user/installations', { per_page: 100 }); + const repositoryPages = await Promise.all( + installations + .filter((installation) => installation.permissions.contents === 'write') + .map(async (installation) => + octokit.paginate('GET /user/installations/{installation_id}/repositories', { + installation_id: installation.id, + per_page: 100, + }), + ), + ); + const repositories = [...new Map(repositoryPages.flat().map((repository) => [repository.id, repository])).values()]; + return repositories + .filter((repository) => repository.permissions?.push || repository.permissions?.admin) + .map((repository) => ({ + owner: repository.owner.login, + name: repository.name, + fullName: repository.full_name, + defaultBranch: repository.default_branch, + private: repository.private, + })); +}; + export const githubApi = createApi({ reducerPath: 'githubApi', baseQuery: fakeBaseQuery<{ message: string }>(), @@ -171,32 +250,7 @@ export const githubApi = createApi({ listGitHubRepositories: builder.query({ queryFn: async () => { try { - const octokit = getOctokit(); - const installations = await octokit.paginate('GET /user/installations', { per_page: 100 }); - const repositoryPages = await Promise.all( - installations - .filter((installation) => installation.permissions.contents === 'write') - .map(async (installation) => - octokit.paginate('GET /user/installations/{installation_id}/repositories', { - installation_id: installation.id, - per_page: 100, - }), - ), - ); - const repositories = [ - ...new Map(repositoryPages.flat().map((repository) => [repository.id, repository])).values(), - ]; - return { - data: repositories - .filter((repository) => repository.permissions?.push || repository.permissions?.admin) - .map((repository) => ({ - owner: repository.owner.login, - name: repository.name, - fullName: repository.full_name, - defaultBranch: repository.default_branch, - private: repository.private, - })), - }; + return { data: await listAccessibleGitHubRepositories() }; } catch (error) { return { error: { message: getErrorMessage(error) } }; } @@ -216,7 +270,7 @@ export const githubApi = createApi({ providesTags: ['Branches'], }), getGitHubWorkspaceSettings: builder.query< - { docsRoot: string; schemaVersion: number } | null, + GitHubWorkspaceSettings | null, { owner: string; repo: string; branch: string } >({ queryFn: async ({ owner, repo, branch }) => { @@ -224,7 +278,7 @@ export const githubApi = createApi({ const response = await getOctokit().rest.repos.getContent({ owner, repo, - path: '.workspace-settings.json', + path: WORKSPACE_SETTINGS_PATH, ref: branch, }); if (Array.isArray(response.data) || response.data.type !== 'file' || !('content' in response.data)) { @@ -232,10 +286,23 @@ export const githubApi = createApi({ } const value = JSON.parse(decodeBase64Utf8(response.data.content)) as { docsRoot?: unknown; + ignoreDirs?: unknown; schemaVersion?: unknown; }; if (value.schemaVersion !== 1 || typeof value.docsRoot !== 'string') return { data: null }; - return { data: { schemaVersion: 1, docsRoot: value.docsRoot } }; + if ( + value.ignoreDirs !== undefined && + (!Array.isArray(value.ignoreDirs) || value.ignoreDirs.some((item) => typeof item !== 'string')) + ) { + return { data: null }; + } + return { + data: { + schemaVersion: 1, + docsRoot: value.docsRoot, + ignoreDirs: value.ignoreDirs === undefined ? [...DEFAULT_IGNORE_DIRS] : (value.ignoreDirs as string[]), + }, + }; } catch (error) { if ((error as { status?: number }).status === 404) return { data: null }; return { error: { message: getErrorMessage(error) } }; @@ -269,7 +336,7 @@ export const githubApi = createApi({ }, }), createGitHubRepository: builder.mutation< - { config: WorkspaceDescriptor; snapshot: GitHubWorkspaceSnapshot }, + { config: GitHubWorkspaceConfig; snapshot: GitHubWorkspaceSnapshot }, CreateGitHubRepositoryPayload >({ queryFn: async (payload) => { @@ -285,6 +352,7 @@ export const githubApi = createApi({ repo: response.data.name, branch: response.data.default_branch, docsRoot: payload.docsRoot, + ignoreDirs: [...DEFAULT_IGNORE_DIRS], }; const snapshot = await initializeWorkspace(partialConfig); return { @@ -299,7 +367,7 @@ export const githubApi = createApi({ }, invalidatesTags: ['Repositories'], }), - initializeGitHubWorkspace: builder.mutation({ + initializeGitHubWorkspace: builder.mutation({ queryFn: async (config) => { try { return { data: await initializeWorkspace(config) }; @@ -312,52 +380,18 @@ export const githubApi = createApi({ publishGitHubWorkspace: builder.mutation({ queryFn: async ({ config, plan, title, body }) => { try { - const octokit = getOctokit(); const current = await getSnapshot(config); if (current.baseCommitSha !== plan.baseCommitSha) { throw new Error( 'The selected branch changed on GitHub. Rebase or discard local changes before publishing.', ); } - - const treeEntries = await Promise.all( - plan.entries.map(async (entry) => { - if (entry.sha === null && entry.content === undefined) { - return { path: entry.path, mode: entry.mode, type: entry.type, sha: null }; - } - let sha = entry.sha; - if (entry.content !== undefined) { - const blob = await octokit.request('POST /repos/{owner}/{repo}/git/blobs', { - owner: config.owner, - repo: config.repo, - content: entry.content, - encoding: 'utf-8', - }); - sha = blob.data.sha; - } - if (!sha) throw new Error(`No content is available for ${entry.path}.`); - return { path: entry.path, mode: entry.mode, type: entry.type, sha }; - }), - ); - const tree = await octokit.request('POST /repos/{owner}/{repo}/git/trees', { - owner: config.owner, - repo: config.repo, - base_tree: plan.baseTreeSha, - tree: treeEntries, - }); - const commit = await octokit.request('POST /repos/{owner}/{repo}/git/commits', { - owner: config.owner, - repo: config.repo, + await commitTreeToBranch({ + config, + baseCommitSha: plan.baseCommitSha, + baseTreeSha: plan.baseTreeSha, + entries: plan.entries, message: body.trim() ? `${title}\n\n${body}` : title, - tree: tree.data.sha, - parents: [plan.baseCommitSha], - }); - await octokit.request('PATCH /repos/{owner}/{repo}/git/refs/{ref}', { - owner: config.owner, - repo: config.repo, - ref: `heads/${config.branch}`, - sha: commit.data.sha, - force: false, }); return { data: await getSnapshot(config) }; } catch (error) { diff --git a/client/src/redux-feature/githubWorkspaceSlice.ts b/client/src/redux-feature/githubWorkspaceSlice.ts index cfc1186..89ac9c4 100644 --- a/client/src/redux-feature/githubWorkspaceSlice.ts +++ b/client/src/redux-feature/githubWorkspaceSlice.ts @@ -3,8 +3,12 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/store'; import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; +import { DEFAULT_IGNORE_DIRS } from '@/constants'; + export type WorkspaceMode = 'github' | 'local'; -export type GitHubWorkspaceConfig = WorkspaceDescriptor; +export interface GitHubWorkspaceConfig extends WorkspaceDescriptor { + ignoreDirs: string[]; +} interface GitHubWorkspaceState { mode: WorkspaceMode; @@ -16,6 +20,7 @@ export const emptyConfig: GitHubWorkspaceConfig = { repo: '', branch: '', docsRoot: 'docs', + ignoreDirs: [...DEFAULT_IGNORE_DIRS], }; const MODE_STORAGE_KEY = 'workspace-mode'; @@ -25,21 +30,24 @@ const readMode = (): WorkspaceMode => (window.localStorage.getItem(MODE_STORAGE_ const readConfig = (): GitHubWorkspaceConfig => { try { - const value = window.localStorage.getItem(CONFIG_STORAGE_KEY); - if (!value) return emptyConfig; - const parsed = JSON.parse(value) as Partial; + const storedConfig = window.localStorage.getItem(CONFIG_STORAGE_KEY); + if (!storedConfig) return emptyConfig; + const parsed = JSON.parse(storedConfig) as Partial; return { owner: parsed.owner ?? '', repo: parsed.repo ?? '', branch: parsed.branch ?? '', docsRoot: parsed.docsRoot ?? 'docs', + ignoreDirs: Array.isArray(parsed.ignoreDirs) + ? parsed.ignoreDirs.filter((value): value is string => typeof value === 'string') + : [...DEFAULT_IGNORE_DIRS], }; } catch { return emptyConfig; } }; -export const getGitHubWorkspaceKey = (config: GitHubWorkspaceConfig) => +export const getGitHubWorkspaceKey = (config: WorkspaceDescriptor) => [config.owner, config.repo, config.branch, config.docsRoot].join('/'); const initialState: GitHubWorkspaceState = { diff --git a/client/src/theme/cyber.scss b/client/src/theme/cyber.scss index 355ac20..3fbd1c8 100644 --- a/client/src/theme/cyber.scss +++ b/client/src/theme/cyber.scss @@ -661,7 +661,7 @@ html.cyber .sidebar-overlay.p-overlaypanel .git-box .op-box { border-bottom: 1px solid var(--panelBorderColor); } -html.cyber .sidebar-overlay.p-overlaypanel .git-box .op-box .icon-wrapper { +html.cyber .sidebar-overlay.p-overlaypanel .git-box:not(.github-changes-box) .op-box .icon-wrapper { margin-left: 0; } diff --git a/client/src/utils/githubWorkspace.ts b/client/src/utils/githubWorkspace.ts index 890de09..63fe0f3 100644 --- a/client/src/utils/githubWorkspace.ts +++ b/client/src/utils/githubWorkspace.ts @@ -1,11 +1,15 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ /* eslint-disable no-control-regex */ -import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; +import type { WorkspaceChange, WorkspaceDescriptor } from '@markdown-editor/github-workspace'; +import { GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER } from '@/constants'; import { denormalizePath, normalizePath } from '@/utils/utils'; const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); +export const getVisibleGitHubWorkspaceChanges = (changes: WorkspaceChange[]) => + changes.filter((change) => !change.path.endsWith(`/${GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER}`)); + export const getRepositoryDocPath = (config: WorkspaceDescriptor, logicalPath: string, isFile: boolean) => { const relative = denormalizePath(logicalPath).filter(Boolean).join('/'); const path = [cleanPath(config.docsRoot), relative].filter(Boolean).join('/'); diff --git a/client/src/utils/githubWorkspaceRuntime.ts b/client/src/utils/githubWorkspaceRuntime.ts index 1c54750..07f1e0c 100644 --- a/client/src/utils/githubWorkspaceRuntime.ts +++ b/client/src/utils/githubWorkspaceRuntime.ts @@ -1,7 +1,27 @@ -import { GitWorkspaceStore, type WorkspaceSnapshot } from '@markdown-editor/github-workspace'; +import { + GitWorkspaceStore, + IndexedDbWorkspacePersistence, + type WorkspaceSnapshot, +} from '@markdown-editor/github-workspace'; import { useSyncExternalStore } from 'react'; -export const githubWorkspaceStore = new GitWorkspaceStore(); +import { + GITHUB_WORKSPACE_DATABASE_NAME, + GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, + GITHUB_WORKSPACE_STORE_NAME, + WORKSPACE_SETTINGS_PATH, +} from '@/constants'; + +export const githubWorkspaceStore = new GitWorkspaceStore( + new IndexedDbWorkspacePersistence({ + databaseName: GITHUB_WORKSPACE_DATABASE_NAME, + storeName: GITHUB_WORKSPACE_STORE_NAME, + }), + { + emptyDirectoryMarker: GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, + managedPaths: [WORKSPACE_SETTINGS_PATH], + }, +); export const useGitHubWorkspaceSnapshot = (): WorkspaceSnapshot => useSyncExternalStore( diff --git a/client/src/utils/hooks/githubAuthHooks.ts b/client/src/utils/hooks/githubAuthHooks.ts index 435d2b4..24c77f7 100644 --- a/client/src/utils/hooks/githubAuthHooks.ts +++ b/client/src/utils/hooks/githubAuthHooks.ts @@ -1,10 +1,14 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState, useSyncExternalStore } from 'react'; + +import Toast from '@/utils/Toast'; const GITHUB_OAUTH_WORKER_URL = 'https://markdown-editor-github-auth.s-elo.workers.dev/'; export const GITHUB_ACCESS_TOKEN_STORAGE_KEY = 'github-access-token'; const GITHUB_OAUTH_STATE_STORAGE_KEY = 'github-oauth-state'; const OAUTH_QUERY_PARAMS = ['code', 'state', 'error', 'error_description']; +type GitHubAuthFlow = 'install' | 'login'; + interface GitHubTokenResponse { token?: string; error?: string; @@ -21,7 +25,43 @@ interface GitHubUser { avatarUrl: string | null; } -export const getGitHubAccessToken = () => window.localStorage.getItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); +let githubAccessToken = window.localStorage.getItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); +const githubAccessTokenListeners = new Set<() => void>(); + +const notifyGitHubAccessTokenListeners = () => { + githubAccessTokenListeners.forEach((listener) => { + listener(); + }); +}; + +const setGitHubAccessToken = (token: string | null) => { + if (token === githubAccessToken) return; + githubAccessToken = token; + if (token) { + window.localStorage.setItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY, token); + } else { + window.localStorage.removeItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); + } + notifyGitHubAccessTokenListeners(); +}; + +const subscribeToGitHubAccessToken = (listener: () => void) => { + githubAccessTokenListeners.add(listener); + return () => { + githubAccessTokenListeners.delete(listener); + }; +}; + +window.addEventListener('storage', (event) => { + if (event.key !== GITHUB_ACCESS_TOKEN_STORAGE_KEY || event.newValue === githubAccessToken) return; + githubAccessToken = event.newValue; + notifyGitHubAccessTokenListeners(); +}); + +export const getGitHubAccessToken = () => githubAccessToken; + +export const useGitHubAccessToken = () => + useSyncExternalStore(subscribeToGitHubAccessToken, getGitHubAccessToken, getGitHubAccessToken); const getUrlWithoutOAuthParams = () => { const url = new URL(window.location.href); @@ -36,6 +76,26 @@ const removeOAuthParamsFromUrl = () => { window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`); }; +const startGitHubAuth = (flow: GitHubAuthFlow) => { + const oauthState = window.crypto.randomUUID(); + const workerUrl = new URL(GITHUB_OAUTH_WORKER_URL); + workerUrl.searchParams.set('return_to', getUrlWithoutOAuthParams().toString()); + workerUrl.searchParams.set('client_state', oauthState); + if (flow === 'login') { + workerUrl.searchParams.set('flow', 'login'); + } + window.sessionStorage.setItem(GITHUB_OAUTH_STATE_STORAGE_KEY, oauthState); + window.location.assign(workerUrl.toString()); +}; + +export const startGitHubLogin = () => { + startGitHubAuth('login'); +}; + +export const startGitHubInstall = () => { + startGitHubAuth('install'); +}; + const getGitHubUser = async (token: string): Promise => { const response = await fetch('https://api.github.com/user', { headers: { @@ -60,6 +120,7 @@ const getGitHubUser = async (token: string): Promise => { /** Manages the GitHub OAuth callback and persisted access token. */ export const useGitHubLogin = () => { const oauthInProgressRef = useRef(false); + const accessToken = useGitHubAccessToken(); const [githubLogin, setGithubLogin] = useState(null); const [githubAvatarUrl, setGithubAvatarUrl] = useState(null); const [isGitHubLoginLoading, setIsGitHubLoginLoading] = useState(true); @@ -70,29 +131,6 @@ export const useGitHubLogin = () => { const oauthError = url.searchParams.get('error'); const oauthState = url.searchParams.get('state'); - const restoreStoredSession = async () => { - const token = window.localStorage.getItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); - if (!token) { - setIsGitHubLoginLoading(false); - return; - } - - try { - const user = await getGitHubUser(token); - if (user) { - setGithubLogin(user.login); - setGithubAvatarUrl(user.avatarUrl); - } else { - // GitHub returns a non-success response for expired or revoked tokens. - window.localStorage.removeItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); - } - } catch { - // Keep the token while offline; validate it again on the next app load. - } finally { - setIsGitHubLoginLoading(false); - } - }; - const completeOAuthLogin = async (authorizationCode: string) => { oauthInProgressRef.current = true; setIsGitHubLoginLoading(true); @@ -117,13 +155,14 @@ export const useGitHubLogin = () => { throw new Error('GitHub returned a token that could not access the authenticated user.'); } - window.localStorage.setItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY, result.token); + setGitHubAccessToken(result.token); setGithubLogin(user.login); setGithubAvatarUrl(user.avatarUrl); } catch (error) { const message = error instanceof Error ? error.message : 'GitHub authentication failed.'; - window.alert(message); + Toast.error(message); } finally { + oauthInProgressRef.current = false; setIsGitHubLoginLoading(false); } }; @@ -135,7 +174,7 @@ export const useGitHubLogin = () => { if (!oauthState || oauthState !== expectedOAuthState) { removeOAuthParamsFromUrl(); setIsGitHubLoginLoading(false); - window.alert('GitHub authentication failed because the OAuth state was invalid.'); + Toast.error('GitHub authentication failed because the OAuth state was invalid.'); } else { void completeOAuthLogin(code); } @@ -143,31 +182,56 @@ export const useGitHubLogin = () => { window.sessionStorage.removeItem(GITHUB_OAUTH_STATE_STORAGE_KEY); removeOAuthParamsFromUrl(); setIsGitHubLoginLoading(false); - window.alert(`GitHub authentication was cancelled: ${oauthError}`); - } else { - void restoreStoredSession(); + Toast.warn(`GitHub authentication was cancelled: ${oauthError}`); } }, []); + useEffect(() => { + if (!accessToken) { + setGithubLogin(null); + setGithubAvatarUrl(null); + if (!oauthInProgressRef.current) setIsGitHubLoginLoading(false); + return; + } + + let cancelled = false; + setIsGitHubLoginLoading(true); + void getGitHubUser(accessToken) + .then((user) => { + if (cancelled) return; + if (!user) { + // GitHub returns a non-success response for expired or revoked tokens. + setGitHubAccessToken(null); + return; + } + setGithubLogin(user.login); + setGithubAvatarUrl(user.avatarUrl); + }) + .catch(() => { + // Keep the token while offline; validate it again on the next app load. + }) + .finally(() => { + if (!cancelled) setIsGitHubLoginLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [accessToken]); + return { githubLogin, githubAvatarUrl, isGitHubLoginLoading, logoutGitHub: () => { - window.localStorage.removeItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); + setGitHubAccessToken(null); window.sessionStorage.removeItem(GITHUB_OAUTH_STATE_STORAGE_KEY); setGithubLogin(null); setGithubAvatarUrl(null); setIsGitHubLoginLoading(false); oauthInProgressRef.current = false; }, - startGitHubLogin: () => { - const oauthState = window.crypto.randomUUID(); - const workerUrl = new URL(GITHUB_OAUTH_WORKER_URL); - workerUrl.searchParams.set('return_to', getUrlWithoutOAuthParams().toString()); - workerUrl.searchParams.set('client_state', oauthState); - window.sessionStorage.setItem(GITHUB_OAUTH_STATE_STORAGE_KEY, oauthState); - window.location.assign(workerUrl.toString()); - }, + startGitHubLogin, + startGitHubInstall, }; }; diff --git a/client/src/utils/hooks/githubInstallationHooks.ts b/client/src/utils/hooks/githubInstallationHooks.ts new file mode 100644 index 0000000..fbf7e17 --- /dev/null +++ b/client/src/utils/hooks/githubInstallationHooks.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; + +import { listAccessibleGitHubRepositories } from '@/redux-api/github'; +import { selectGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import { startGitHubInstall, useGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; +import { confirm } from '@/utils/utils'; + +interface InstallationCheck { + repository: string; + token: string; +} + +/** Prompts signed-in users to install/configure the GitHub App when no usable installation is available. */ +export const useGitHubInstallationGuard = () => { + const accessToken = useGitHubAccessToken(); + const githubWorkspaceConfig = useSelector(selectGitHubWorkspaceConfig); + const lastCheckRef = useRef(null); + const configuredRepository = + githubWorkspaceConfig.owner && githubWorkspaceConfig.repo + ? `${githubWorkspaceConfig.owner}/${githubWorkspaceConfig.repo}` + : ''; + + useEffect(() => { + if (!accessToken) { + lastCheckRef.current = null; + return; + } + + const lastCheck = lastCheckRef.current; + if (lastCheck?.token === accessToken && lastCheck.repository === configuredRepository) return; + lastCheckRef.current = { token: accessToken, repository: configuredRepository }; + + let cancelled = false; + void listAccessibleGitHubRepositories() + .then(async (repositories) => { + if (cancelled) return; + const hasRequiredInstallation = configuredRepository + ? repositories.some((repository) => repository.fullName === configuredRepository) + : repositories.length > 0; + if (hasRequiredInstallation) return; + + const shouldInstall = await confirm({ + className: 'github-installation-dialog', + header: 'GitHub App installation required', + acceptLabel: 'Install GitHub App', + rejectLabel: 'Not now', + message: configuredRepository + ? `The GitHub App cannot access ${configuredRepository}. Install it or update its repository access to continue.` + : 'The GitHub App is not installed for any accessible repository. Install it to use a GitHub workspace.', + }); + if (shouldInstall && !cancelled) startGitHubInstall(); + }) + .catch(() => { + // Authentication and network errors are surfaced by the existing GitHub queries. + }); + + return () => { + cancelled = true; + }; + }, [accessToken, configuredRepository]); +}; diff --git a/client/src/utils/hooks/workspaceHooks.ts b/client/src/utils/hooks/workspaceHooks.ts index 70f83d7..52d5638 100644 --- a/client/src/utils/hooks/workspaceHooks.ts +++ b/client/src/utils/hooks/workspaceHooks.ts @@ -16,6 +16,7 @@ import type { import type { RootState } from '@/store'; import type { WorkspaceEntry } from '@markdown-editor/github-workspace'; +import { GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER } from '@/constants'; import { useCopyCutDocMutation, useCreateDocMutation, @@ -34,19 +35,25 @@ import { } from '@/redux-feature/githubWorkspaceSlice'; import { getRepositoryDocPath, getVisibleLogicalPath, validateWorkspaceName } from '@/utils/githubWorkspace'; import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; +import { useGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; import { denormalizePath, normalizePath } from '@/utils/utils'; const MARKDOWN_EXTENSION_LENGTH = 3; const isConfigured = (config: GitHubWorkspaceConfig) => Boolean(config.owner && config.repo && config.branch); -const isHiddenMenuEntry = (entry: WorkspaceEntry) => { +const isHiddenMenuEntry = (config: GitHubWorkspaceConfig, entry: WorkspaceEntry) => { const name = entry.path.split('/').at(-1) ?? ''; - return name === '.gitkeep' || name === '_assets' || name.startsWith('.'); + return ( + name === GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER || + name === '_assets' || + name.startsWith('.') || + (entry.kind === 'directory' && config.ignoreDirs.includes(name)) + ); }; const toDocTreeNode = (config: GitHubWorkspaceConfig, entry: WorkspaceEntry): DocTreeNode | null => { - if (isHiddenMenuEntry(entry)) return null; + if (isHiddenMenuEntry(config, entry)) return null; const name = entry.path.split('/').at(-1) ?? ''; if (entry.kind === 'file' && !name.endsWith('.md')) return null; const logicalPath = getVisibleLogicalPath(config, entry.path); @@ -70,6 +77,7 @@ const listGitHubSubItems = (config: GitHubWorkspaceConfig, logicalFolderPath = ' /** Activates the keyed package store, then attaches the RTK-fetched remote manifest. */ export const useGitHubWorkspaceSync = () => { const workspace = useSelector(selectGithubWorkspace); + const githubAccessToken = useGitHubAccessToken(); const snapshot = useGitHubWorkspaceSnapshot(); const configured = isConfigured(workspace.config); const activeWorkspace = snapshot.descriptor @@ -77,26 +85,30 @@ export const useGitHubWorkspaceSync = () => { : false; useEffect(() => { - if (workspace.mode !== 'github' || !configured) { + if (workspace.mode !== 'github' || !configured || !githubAccessToken) { githubWorkspaceStore.close(); return; } void githubWorkspaceStore.open(workspace.config); - }, [configured, workspace.config, workspace.mode]); + }, [configured, githubAccessToken, workspace.config, workspace.mode]); const query = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, + workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.hydrated + ? workspace.config + : skipToken, + { refetchOnMountOrArgChange: true }, ); useEffect(() => { - if (query.data) void githubWorkspaceStore.attachRemote(query.data); - }, [query.data]); + if (githubAccessToken && query.data) void githubWorkspaceStore.attachRemote(query.data); + }, [githubAccessToken, query.data]); return query; }; export const useWorkspaceRootItemsQuery = () => { const workspace = useSelector(selectGithubWorkspace); + const githubAccessToken = useGitHubAccessToken(); const snapshot = useGitHubWorkspaceSnapshot(); const localQuery = useGetDocSubItemsQuery(undefined, { skip: workspace.mode === 'github', @@ -107,20 +119,36 @@ export const useWorkspaceRootItemsQuery = () => { ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) : false; const githubQuery = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, + workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.hydrated + ? workspace.config + : skipToken, + { refetchOnMountOrArgChange: true }, ); const data = useMemo( () => - workspace.mode === 'github' && activeWorkspace && snapshot.hydrated ? listGitHubSubItems(workspace.config) : [], - [activeWorkspace, snapshot.revision, snapshot.hydrated, workspace.config, workspace.mode], + workspace.mode === 'github' && githubAccessToken && activeWorkspace && snapshot.hydrated + ? listGitHubSubItems(workspace.config) + : [], + [activeWorkspace, githubAccessToken, snapshot.revision, snapshot.hydrated, workspace.config, workspace.mode], ); if (workspace.mode === 'local') return localQuery; + if (!githubAccessToken) { + return { + data: [], + error: { message: 'Sign in to GitHub to use this workspace.' }, + isError: true, + isFetching: false, + isSuccess: false, + refetch: async () => ({ data: [] }), + }; + } return { data, error: githubQuery.error, isError: githubQuery.isError, isFetching: - configured && (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) && !githubQuery.isError, + githubQuery.isFetching || + (configured && (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) && !githubQuery.isError), isSuccess: (activeWorkspace && snapshot.hydrated && Boolean(snapshot.baseCommitSha)) || !configured, refetch: async () => ({ data }), }; diff --git a/packages/github-workspace/src/constants.ts b/packages/github-workspace/src/constants.ts index f626a3e..4646ddb 100644 --- a/packages/github-workspace/src/constants.ts +++ b/packages/github-workspace/src/constants.ts @@ -1,6 +1,2 @@ -export const DATABASE_NAME = 'markdown-editor-github-workspaces'; -export const WORKSPACE_STORE_NAME = 'workspaces-v3'; -export const DATABASE_VERSION = 3; -export const WORKSPACE_SCHEMA_VERSION = 3; -export const EMPTY_DIRECTORY_MARKER = '.gitkeep'; -export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json'; +export const DATABASE_VERSION = 1; +export const WORKSPACE_SCHEMA_VERSION = 1; diff --git a/packages/github-workspace/src/git-status.ts b/packages/github-workspace/src/git-status.ts index 8334114..41384f1 100644 --- a/packages/github-workspace/src/git-status.ts +++ b/packages/github-workspace/src/git-status.ts @@ -1,5 +1,4 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ -import { EMPTY_DIRECTORY_MARKER } from './constants'; import { GitMode } from './types'; import { cleanPath, makeMetadata, parentPath } from './utils'; @@ -9,6 +8,7 @@ import type { RemoteTreeEntry, TreeState, WorkspaceChange, + WorkspaceConventions, WorkspaceDescriptor, WorkspaceEntry, WorkspaceStatus, @@ -91,15 +91,16 @@ const sameEntry = (left?: WorkspaceEntry, right?: WorkspaceEntry) => const entryMetadata = (tree: TreeState, path: string, entry?: WorkspaceEntry) => entry?.metadata ?? tree.deletedMetadata[path]; -const isEmptyDirectory = (tree: TreeState, path: string) => +const isEmptyDirectory = (tree: TreeState, path: string, emptyDirectoryMarker: string) => !Object.values(tree.entries).some( - (entry) => parentPath(entry.path) === path && entry.path !== `${path}/${EMPTY_DIRECTORY_MARKER}`, + (entry) => parentPath(entry.path) === path && entry.path !== `${path}/${emptyDirectoryMarker}`, ); export const treeToGitStatus = ( from: TreeState, to: TreeState, addedStatus: 'ADDED' | 'UNTRACKED', + conventions: WorkspaceConventions, ): WorkspaceChange[] => { const paths = new Set([...Object.keys(from.entries), ...Object.keys(to.entries)]); return [...paths] @@ -110,8 +111,10 @@ export const treeToGitStatus = ( if (sameEntry(oldEntry, newEntry)) return []; // Directories are a UI concept rather than Git tree leaves. Only empty-directory changes need their own status. if ( - (newEntry?.kind === 'directory' && !oldEntry && !isEmptyDirectory(to, path)) || - (oldEntry?.kind === 'directory' && !newEntry && !isEmptyDirectory(from, path)) + (newEntry?.kind === 'directory' && + !oldEntry && + !isEmptyDirectory(to, path, conventions.emptyDirectoryMarker)) || + (oldEntry?.kind === 'directory' && !newEntry && !isEmptyDirectory(from, path, conventions.emptyDirectoryMarker)) ) { return []; } @@ -134,9 +137,11 @@ export const treeToGitStatus = ( }); }; -export const getWorkingChanges = (index: TreeState, working: TreeState) => treeToGitStatus(index, working, 'UNTRACKED'); +export const getWorkingChanges = (index: TreeState, working: TreeState, conventions: WorkspaceConventions) => + treeToGitStatus(index, working, 'UNTRACKED', conventions); -export const getStagedChanges = (base: TreeState, index: TreeState) => treeToGitStatus(base, index, 'ADDED'); +export const getStagedChanges = (base: TreeState, index: TreeState, conventions: WorkspaceConventions) => + treeToGitStatus(base, index, 'ADDED', conventions); export const gitStatusToTree = (base: TreeState, changes: WorkspaceChange[], root = '') => { const result = cloneTree(base); @@ -168,8 +173,13 @@ export const gitStatusToTree = (base: TreeState, changes: WorkspaceChange[], roo return result; }; -export const stagedChangesToPublishEntries = (base: TreeState, index: TreeState, root = ''): PublishTreeEntry[] => - treeToGitStatus(base, gitStatusToTree(base, getStagedChanges(base, index), root), 'ADDED') +export const stagedChangesToPublishEntries = ( + base: TreeState, + index: TreeState, + root: string, + conventions: WorkspaceConventions, +): PublishTreeEntry[] => + treeToGitStatus(base, gitStatusToTree(base, getStagedChanges(base, index, conventions), root), 'ADDED', conventions) .filter((change) => change.kind === 'file') .map((change) => { if (!change.newEntry) { @@ -189,8 +199,8 @@ export const stagedChangesToPublishEntries = (base: TreeState, index: TreeState, }; }); -export const changedPaths = (left: TreeState, right: TreeState) => - treeToGitStatus(left, right, 'ADDED').map((change) => change.path); +export const changedPaths = (left: TreeState, right: TreeState, conventions: WorkspaceConventions) => + treeToGitStatus(left, right, 'ADDED', conventions).map((change) => change.path); export const copyPathState = (source: TreeState, destination: TreeState, path: string) => { const entry = source.entries[path]; @@ -201,8 +211,14 @@ export const copyPathState = (source: TreeState, destination: TreeState, path: s else delete destination.deletedMetadata[path]; }; -export const applyGitStatusToTree = (from: TreeState, desired: TreeState, onto: TreeState, root = '') => { - return gitStatusToTree(onto, treeToGitStatus(from, desired, 'ADDED'), root); +export const applyGitStatusToTree = ( + from: TreeState, + desired: TreeState, + onto: TreeState, + root: string, + conventions: WorkspaceConventions, +) => { + return gitStatusToTree(onto, treeToGitStatus(from, desired, 'ADDED', conventions), root); }; export const getPathMappings = (before: TreeState, after: TreeState) => { diff --git a/packages/github-workspace/src/index.ts b/packages/github-workspace/src/index.ts index 670ef79..4dd0850 100644 --- a/packages/github-workspace/src/index.ts +++ b/packages/github-workspace/src/index.ts @@ -22,7 +22,7 @@ import { getWorkingChanges, stagedChangesToPublishEntries, } from './git-status'; -import { createPersistedWorkspace, IndexedDbWorkspacePersistence } from './persistence'; +import { createPersistedWorkspace } from './persistence'; import { cleanPath, createId, getWorkspaceKey, makeMetadata, parentPath, pathsOverlap } from './utils'; import { assertInScope, @@ -49,6 +49,7 @@ import type { RemoteWorkspaceSnapshot, TreeState, WorkspaceDescriptor, + WorkspaceConventions, WorkspacePersistence, WorkspaceSnapshot, } from './types'; @@ -90,7 +91,10 @@ export class GitWorkspaceStore { private snapshotCache: WorkspaceSnapshot | undefined; - public constructor(private readonly persistence: WorkspacePersistence = new IndexedDbWorkspacePersistence()) {} + public constructor( + private readonly persistence: WorkspacePersistence, + private readonly conventions: WorkspaceConventions, + ) {} public subscribe = (listener: () => void) => { this.listeners.add(listener); @@ -176,15 +180,15 @@ export class GitWorkspaceStore { } public getWorkingChanges() { - return getWorkingChanges(this.index, this.working); + return getWorkingChanges(this.index, this.working, this.conventions); } public getStagedChanges() { - return getStagedChanges(this.base, this.index); + return getStagedChanges(this.base, this.index, this.conventions); } public listDirectory(path: string) { - return listDirectoryEntries(this.working, path); + return listDirectoryEntries(this.working, path, this.conventions); } public getEntry(path: string) { @@ -203,21 +207,21 @@ export class GitWorkspaceStore { } public async createFile(path: string, content = '') { - assertInScope(this.descriptor, path); + assertInScope(this.descriptor, path, this.conventions); return this._mutate(`Create file ${path}`, [path], (tree, metadata) => { - createFileEntry(tree, this._requireDescriptor(), path, content, metadata); + createFileEntry(tree, this._requireDescriptor(), path, content, metadata, this.conventions); }); } public async createDirectory(path: string) { - assertInScope(this.descriptor, path); + assertInScope(this.descriptor, path, this.conventions); return this._mutate(`Create folder ${path}`, [path], (tree, metadata) => { - createDirectory(tree, this._requireDescriptor(), path, metadata); + createDirectory(tree, this._requireDescriptor(), path, metadata, this.conventions); }); } public async writeFile(path: string, content: string) { - assertInScope(this.descriptor, path); + assertInScope(this.descriptor, path, this.conventions); const current = this.working.entries[path]; if (!current || current.kind !== 'file') return Promise.reject(new Error(`The file ${path} does not exist.`)); const metadata = current.metadata ?? makeMetadata(`Update ${path}`, [path]); @@ -227,9 +231,9 @@ export class GitWorkspaceStore { } public async delete(path: string) { - assertInScope(this.descriptor, path); + assertInScope(this.descriptor, path, this.conventions); return this._mutate(`Delete ${path}`, [path], (tree, metadata) => { - deleteWorkspaceEntry(tree, this.base, path, metadata); + deleteWorkspaceEntry(tree, this.base, path, metadata, this.conventions); }); } @@ -244,7 +248,7 @@ export class GitWorkspaceStore { public async stage(groupIds: string[]) { return this._enqueue(async () => { this._assertStagingUnlocked(); - stageChanges(this.base, this.index, this.working, groupIds); + stageChanges(this.base, this.index, this.working, groupIds, this.conventions); this.revision += 1; await this._persistAndEmit(); return this._result(); @@ -264,7 +268,7 @@ export class GitWorkspaceStore { public async unstage(groupIds: string[]) { return this._enqueue(async () => { this._assertStagingUnlocked(); - unstageChanges(this.base, this.index, groupIds); + unstageChanges(this.base, this.index, groupIds, this.conventions); this.revision += 1; await this._persistAndEmit(); return this._result(); @@ -274,7 +278,7 @@ export class GitWorkspaceStore { public async restoreWorking(groupIds: string[]) { return this._enqueue(async () => { const before = cloneTree(this.working); - restoreWorkingChanges(this.index, this.working, groupIds); + restoreWorkingChanges(this.index, this.working, groupIds, this.conventions); this.revision += 1; await this._persistAndEmit(); return this._result(getPathMappings(before, this.working)); @@ -297,7 +301,7 @@ export class GitWorkspaceStore { return this._enqueue(async () => { const descriptor = this._requireDescriptor(); const remote = createBaseTree(descriptor, snapshot.entries); - const remoteChanges = changedPaths(this.base, remote); + const remoteChanges = changedPaths(this.base, remote, this.conventions); const scopes = [...this.getWorkingChanges(), ...this.getStagedChanges()].flatMap((change) => change.scopePaths); const conflicts = remoteChanges.filter((path) => scopes.some((scope) => pathsOverlap(path, scope))); if (conflicts.length) { @@ -307,8 +311,8 @@ export class GitWorkspaceStore { } const oldBase = this.base; this.base = remote; - this.index = applyGitStatusToTree(oldBase, this.index, remote, descriptor.docsRoot); - this.working = applyGitStatusToTree(oldBase, this.working, this.index, descriptor.docsRoot); + this.index = applyGitStatusToTree(oldBase, this.index, remote, descriptor.docsRoot, this.conventions); + this.working = applyGitStatusToTree(oldBase, this.working, this.index, descriptor.docsRoot, this.conventions); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; this.remoteStale = false; @@ -328,7 +332,7 @@ export class GitWorkspaceStore { token, baseCommitSha: this.baseCommitSha, baseTreeSha: this.baseTreeSha, - entries: stagedChangesToPublishEntries(this.base, this.index, this.descriptor?.docsRoot), + entries: stagedChangesToPublishEntries(this.base, this.index, this.descriptor?.docsRoot ?? '', this.conventions), }; } @@ -340,7 +344,7 @@ export class GitWorkspaceStore { const nextBase = createBaseTree(descriptor, snapshot.entries); this.base = nextBase; this.index = cloneTree(nextBase); - this.working = applyGitStatusToTree(previousIndex, this.working, nextBase, descriptor.docsRoot); + this.working = applyGitStatusToTree(previousIndex, this.working, nextBase, descriptor.docsRoot, this.conventions); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; this.publishingToken = ''; @@ -358,8 +362,8 @@ export class GitWorkspaceStore { } protected async _transfer(source: string, destination: string, isCopy: boolean) { - assertInScope(this.descriptor, source); - assertInScope(this.descriptor, destination); + assertInScope(this.descriptor, source, this.conventions); + assertInScope(this.descriptor, destination, this.conventions); if (!this.working.entries[source]) return Promise.reject(new Error(`${source} does not exist.`)); const { existing, metadata } = getTransferMetadata(this.working, this.index, source, destination, isCopy); return this._mutateWithMetadata(metadata, (tree) => { @@ -372,6 +376,7 @@ export class GitWorkspaceStore { destination, isCopy, metadata, + this.conventions, ); }); } @@ -437,6 +442,7 @@ export class GitWorkspaceStore { working: this.working, revision: this.revision, remoteStale: this.remoteStale, + conventions: this.conventions, }), ); } diff --git a/packages/github-workspace/src/persistence.ts b/packages/github-workspace/src/persistence.ts index 07a3343..abadc94 100644 --- a/packages/github-workspace/src/persistence.ts +++ b/packages/github-workspace/src/persistence.ts @@ -1,7 +1,14 @@ -import { DATABASE_NAME, DATABASE_VERSION, WORKSPACE_SCHEMA_VERSION, WORKSPACE_STORE_NAME } from './constants'; +import { DATABASE_VERSION, WORKSPACE_SCHEMA_VERSION } from './constants'; import { cloneTree, getStagedChanges, getWorkingChanges } from './git-status'; -import type { PersistedWorkspace, TreeState, WorkspaceDescriptor, WorkspacePersistence } from './types'; +import type { + IndexedDbWorkspacePersistenceOptions, + PersistedWorkspace, + TreeState, + WorkspaceConventions, + WorkspaceDescriptor, + WorkspacePersistence, +} from './types'; interface PersistedWorkspaceInput { descriptor: WorkspaceDescriptor; @@ -12,6 +19,7 @@ interface PersistedWorkspaceInput { working: TreeState; revision: number; remoteStale: boolean; + conventions: WorkspaceConventions; } export const createPersistedWorkspace = (input: PersistedWorkspaceInput): PersistedWorkspace => ({ @@ -20,15 +28,15 @@ export const createPersistedWorkspace = (input: PersistedWorkspaceInput): Persis baseCommitSha: input.baseCommitSha, baseTreeSha: input.baseTreeSha, base: cloneTree(input.base), - workingChanges: getWorkingChanges(input.index, input.working), - stagedChanges: getStagedChanges(input.base, input.index), + workingChanges: getWorkingChanges(input.index, input.working, input.conventions), + stagedChanges: getStagedChanges(input.base, input.index, input.conventions), revision: input.revision, remoteStale: input.remoteStale, }); -const openDatabase = async (): Promise => +const openDatabase = async ({ databaseName, storeName }: IndexedDbWorkspacePersistenceOptions): Promise => new Promise((resolve, reject) => { - const request = window.indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + const request = window.indexedDB.open(databaseName, DATABASE_VERSION); request.onerror = () => { reject(request.error); }; @@ -36,17 +44,22 @@ const openDatabase = async (): Promise => resolve(request.result); }; request.onupgradeneeded = () => { - if (!request.result.objectStoreNames.contains(WORKSPACE_STORE_NAME)) { - request.result.createObjectStore(WORKSPACE_STORE_NAME); + if (!request.result.objectStoreNames.contains(storeName)) { + request.result.createObjectStore(storeName); } }; }); export class IndexedDbWorkspacePersistence implements WorkspacePersistence { + public constructor(private readonly options: IndexedDbWorkspacePersistenceOptions) {} + public async read(key: string): Promise { - const database = await openDatabase(); + const database = await openDatabase(this.options); return new Promise((resolve, reject) => { - const request = database.transaction(WORKSPACE_STORE_NAME, 'readonly').objectStore(WORKSPACE_STORE_NAME).get(key); + const request = database + .transaction(this.options.storeName, 'readonly') + .objectStore(this.options.storeName) + .get(key); request.onerror = () => { reject(request.error); }; @@ -59,16 +72,16 @@ export class IndexedDbWorkspacePersistence implements WorkspacePersistence { } public async write(key: string, value: PersistedWorkspace): Promise { - const database = await openDatabase(); + const database = await openDatabase(this.options); return new Promise((resolve, reject) => { - const transaction = database.transaction(WORKSPACE_STORE_NAME, 'readwrite'); + const transaction = database.transaction(this.options.storeName, 'readwrite'); transaction.onerror = () => { reject(transaction.error); }; transaction.oncomplete = () => { resolve(); }; - transaction.objectStore(WORKSPACE_STORE_NAME).put(value, key); + transaction.objectStore(this.options.storeName).put(value, key); }).finally(() => { database.close(); }); diff --git a/packages/github-workspace/src/types.ts b/packages/github-workspace/src/types.ts index 1232376..02bd92f 100644 --- a/packages/github-workspace/src/types.ts +++ b/packages/github-workspace/src/types.ts @@ -17,6 +17,16 @@ export interface WorkspaceDescriptor { docsRoot: string; } +export interface WorkspaceConventions { + emptyDirectoryMarker: string; + managedPaths?: readonly string[]; +} + +export interface IndexedDbWorkspacePersistenceOptions { + databaseName: string; + storeName: string; +} + export interface RemoteTreeEntry { path: string; mode: GitMode; diff --git a/packages/github-workspace/src/utils.ts b/packages/github-workspace/src/utils.ts index dae8935..6e78798 100644 --- a/packages/github-workspace/src/utils.ts +++ b/packages/github-workspace/src/utils.ts @@ -1,4 +1,4 @@ -import type { ChangeMetadata, WorkspaceDescriptor } from './types'; +import type { ChangeMetadata, WorkspaceConventions, WorkspaceDescriptor } from './types'; export const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); @@ -19,3 +19,6 @@ export const makeMetadata = (label: string, scopePaths: string[], groupId = crea export const getWorkspaceKey = (descriptor: WorkspaceDescriptor) => [descriptor.owner, descriptor.repo, descriptor.branch, cleanPath(descriptor.docsRoot)].join('/'); + +export const isManagedPath = (path: string, conventions: WorkspaceConventions) => + conventions.managedPaths?.some((managedPath) => cleanPath(managedPath) === path) ?? false; diff --git a/packages/github-workspace/src/workspace-operations.ts b/packages/github-workspace/src/workspace-operations.ts index f909682..95d9376 100644 --- a/packages/github-workspace/src/workspace-operations.ts +++ b/packages/github-workspace/src/workspace-operations.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ -import { EMPTY_DIRECTORY_MARKER, WORKSPACE_SETTINGS_PATH } from './constants'; import { cloneEntry, cloneMetadata, @@ -11,11 +10,15 @@ import { getWorkingChanges, } from './git-status'; import { GitMode } from './types'; -import { cleanPath, createId, isSameOrDescendant, makeMetadata, parentPath } from './utils'; +import { cleanPath, createId, isManagedPath, isSameOrDescendant, makeMetadata, parentPath } from './utils'; -import type { ChangeMetadata, TreeState, WorkspaceDescriptor, WorkspaceEntry } from './types'; +import type { ChangeMetadata, TreeState, WorkspaceConventions, WorkspaceDescriptor, WorkspaceEntry } from './types'; -export const assertInScope = (descriptor: WorkspaceDescriptor | null, path: string) => { +export const assertInScope = ( + descriptor: WorkspaceDescriptor | null, + path: string, + conventions: WorkspaceConventions, +) => { if (!descriptor) throw new Error('No GitHub workspace is open.'); const clean = cleanPath(path); const segments = clean.split('/'); @@ -24,8 +27,8 @@ export const assertInScope = (descriptor: WorkspaceDescriptor | null, path: stri } const root = cleanPath(descriptor.docsRoot); if (root && !clean.startsWith(`${root}/`)) throw new Error(`${path} is outside the configured docs root.`); - if (!root && clean === WORKSPACE_SETTINGS_PATH) { - throw new Error(`${WORKSPACE_SETTINGS_PATH} is managed by the workspace and cannot be changed here.`); + if (isManagedPath(clean, conventions)) { + throw new Error(`${path} is managed by the workspace and cannot be changed here.`); } }; @@ -42,19 +45,24 @@ const ensureParents = (tree: TreeState, descriptor: WorkspaceDescriptor, path: s }); }; -const removeParentMarker = (tree: TreeState, path: string, metadata: ChangeMetadata) => { - const marker = `${parentPath(path)}/${EMPTY_DIRECTORY_MARKER}`.replace(/^\//, ''); +const removeParentMarker = (tree: TreeState, path: string, metadata: ChangeMetadata, emptyDirectoryMarker: string) => { + const marker = `${parentPath(path)}/${emptyDirectoryMarker}`.replace(/^\//, ''); if (!tree.entries[marker]) return; delete tree.entries[marker]; tree.deletedMetadata[marker] = metadata; }; -const ensureEmptyDirectoryMarker = (tree: TreeState, directory: string, metadata: ChangeMetadata) => { +const ensureEmptyDirectoryMarker = ( + tree: TreeState, + directory: string, + metadata: ChangeMetadata, + emptyDirectoryMarker: string, +) => { if (!directory || !tree.entries[directory] || tree.entries[directory].kind !== 'directory') return; const hasChild = Object.values(tree.entries).some( - (entry) => parentPath(entry.path) === directory && entry.path !== `${directory}/${EMPTY_DIRECTORY_MARKER}`, + (entry) => parentPath(entry.path) === directory && entry.path !== `${directory}/${emptyDirectoryMarker}`, ); - const marker = `${directory}/${EMPTY_DIRECTORY_MARKER}`; + const marker = `${directory}/${emptyDirectoryMarker}`; if (!hasChild && !tree.entries[marker]) { tree.entries[marker] = { id: createId(), @@ -68,10 +76,13 @@ const ensureEmptyDirectoryMarker = (tree: TreeState, directory: string, metadata } }; -export const listDirectoryEntries = (tree: TreeState, path: string) => { +export const listDirectoryEntries = (tree: TreeState, path: string, conventions: WorkspaceConventions) => { const directory = cleanPath(path); return Object.values(tree.entries) - .filter((entry) => parentPath(entry.path) === directory && entry.path.split('/').at(-1) !== EMPTY_DIRECTORY_MARKER) + .filter( + (entry) => + parentPath(entry.path) === directory && entry.path.split('/').at(-1) !== conventions.emptyDirectoryMarker, + ) .map(cloneEntry) .sort((left, right) => { if (left.kind !== right.kind) return left.kind === 'directory' ? -1 : 1; @@ -102,10 +113,11 @@ export const createFileEntry = ( path: string, content: string, metadata: ChangeMetadata, + conventions: WorkspaceConventions, ) => { assertAvailable(tree, path); ensureParents(tree, descriptor, path, metadata); - removeParentMarker(tree, path, metadata); + removeParentMarker(tree, path, metadata, conventions.emptyDirectoryMarker); tree.entries[path] = { id: createId(), path, @@ -122,20 +134,21 @@ export const createDirectory = ( descriptor: WorkspaceDescriptor, path: string, metadata: ChangeMetadata, + conventions: WorkspaceConventions, ) => { assertAvailable(tree, path); ensureParents(tree, descriptor, `${path}/child`, metadata); tree.entries[path] = { ...createDirectoryEntry(path), id: createId(), metadata }; - tree.entries[`${path}/${EMPTY_DIRECTORY_MARKER}`] = { + tree.entries[`${path}/${conventions.emptyDirectoryMarker}`] = { id: createId(), - path: `${path}/${EMPTY_DIRECTORY_MARKER}`, + path: `${path}/${conventions.emptyDirectoryMarker}`, kind: 'file', mode: GitMode.File, type: 'blob', content: '', metadata, }; - removeParentMarker(tree, path, metadata); + removeParentMarker(tree, path, metadata, conventions.emptyDirectoryMarker); }; export const writeFileEntry = (tree: TreeState, path: string, content: string, metadata: ChangeMetadata) => { @@ -145,7 +158,13 @@ export const writeFileEntry = (tree: TreeState, path: string, content: string, m entry.metadata = metadata; }; -export const deleteWorkspaceEntry = (tree: TreeState, base: TreeState, path: string, metadata: ChangeMetadata) => { +export const deleteWorkspaceEntry = ( + tree: TreeState, + base: TreeState, + path: string, + metadata: ChangeMetadata, + conventions: WorkspaceConventions, +) => { const targets = Object.keys(tree.entries).filter((entryPath) => isSameOrDescendant(entryPath, path)); if (!targets.length) throw new Error(`${path} does not exist.`); targets.forEach((target) => { @@ -154,7 +173,7 @@ export const deleteWorkspaceEntry = (tree: TreeState, base: TreeState, path: str tree.deletedMetadata[target] = metadata; if (origin) tree.deletedMetadata[origin.path] = metadata; }); - ensureEmptyDirectoryMarker(tree, parentPath(path), metadata); + ensureEmptyDirectoryMarker(tree, parentPath(path), metadata, conventions.emptyDirectoryMarker); }; export const getTransferMetadata = ( @@ -185,6 +204,7 @@ export const transferWorkspaceEntry = ( destination: string, isCopy: boolean, metadata: ChangeMetadata, + conventions: WorkspaceConventions, ) => { if (existing.kind === 'directory' && isSameOrDescendant(destination, source)) { throw new Error('A directory cannot be moved or copied into itself.'); @@ -207,13 +227,19 @@ export const transferWorkspaceEntry = ( aliases.set(entry.path, destinationPath); } }); - removeParentMarker(tree, destination, metadata); - if (!isCopy) ensureEmptyDirectoryMarker(tree, parentPath(source), metadata); + removeParentMarker(tree, destination, metadata, conventions.emptyDirectoryMarker); + if (!isCopy) ensureEmptyDirectoryMarker(tree, parentPath(source), metadata, conventions.emptyDirectoryMarker); }; -export const stageChanges = (base: TreeState, index: TreeState, working: TreeState, groupIds: string[]) => { +export const stageChanges = ( + base: TreeState, + index: TreeState, + working: TreeState, + groupIds: string[], + conventions: WorkspaceConventions, +) => { const selected = new Set(groupIds); - getWorkingChanges(index, working) + getWorkingChanges(index, working, conventions) .filter((change) => selected.has(change.groupId)) .forEach((change) => { copyPathState(working, index, change.path); @@ -224,18 +250,28 @@ export const stageChanges = (base: TreeState, index: TreeState, working: TreeSta }); }; -export const unstageChanges = (base: TreeState, index: TreeState, groupIds: string[]) => { +export const unstageChanges = ( + base: TreeState, + index: TreeState, + groupIds: string[], + conventions: WorkspaceConventions, +) => { const selected = new Set(groupIds); - getStagedChanges(base, index) + getStagedChanges(base, index, conventions) .filter((change) => selected.has(change.groupId)) .forEach((change) => { copyPathState(base, index, change.path); }); }; -export const restoreWorkingChanges = (index: TreeState, working: TreeState, groupIds: string[]) => { +export const restoreWorkingChanges = ( + index: TreeState, + working: TreeState, + groupIds: string[], + conventions: WorkspaceConventions, +) => { const selected = new Set(groupIds); - getWorkingChanges(index, working) + getWorkingChanges(index, working, conventions) .filter((change) => selected.has(change.groupId)) .forEach((change) => { copyPathState(index, working, change.path); From 62b4970b41fd7340bdaa5a7f71af76a30e40c213 Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 28 Aug 2026 17:45:48 +0800 Subject: [PATCH 09/17] feat: refactor the github-workspace package and fix some state sync bugs --- client/src/App.tsx | 4 +- client/src/components/Editor/Editor.tsx | 4 +- .../components/GitBox/GitHubChangesBox.tsx | 82 +-- client/src/components/Menu/Menu.tsx | 31 +- client/src/components/Menu/operations.tsx | 65 +-- client/src/components/Sidebar/Sidebar.tsx | 5 +- client/src/constants.ts | 1 - client/src/redux-api/git.ts | 2 +- client/src/redux-api/github.ts | 13 +- client/src/utils/githubWorkspace.ts | 6 +- client/src/utils/githubWorkspaceRuntime.ts | 10 +- client/src/utils/hooks/reduxHooks.ts | 14 +- client/src/utils/hooks/workspaceHooks.ts | 67 ++- packages/github-workspace/README.md | 108 ++++ packages/github-workspace/src/git-status.ts | 179 +++---- packages/github-workspace/src/index.ts | 494 ++++++++++-------- packages/github-workspace/src/persistence.ts | 23 +- packages/github-workspace/src/types.ts | 52 +- packages/github-workspace/src/utils.ts | 15 +- .../src/workspace-operations.ts | 190 +++---- 20 files changed, 723 insertions(+), 642 deletions(-) create mode 100644 packages/github-workspace/README.md diff --git a/client/src/App.tsx b/client/src/App.tsx index 7fcc2cc..5768414 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,13 +14,13 @@ import { APP_VERSION } from './constants'; import { selectWorkspaceMode } from './redux-feature/githubWorkspaceSlice'; import { selectMenuCollapse } from './redux-feature/globalOptsSlice'; import { useGitHubInstallationGuard } from './utils/hooks/githubInstallationHooks'; -import { useCheckServer, useWarnUnsavedOnUnload } from './utils/hooks/reduxHooks'; +import { useCheckServer, useWarnBeforeUnload } from './utils/hooks/reduxHooks'; import { useGitHubWorkspaceSync } from './utils/hooks/workspaceHooks'; import './App.scss'; export const App: FC = () => { - useWarnUnsavedOnUnload(); + useWarnBeforeUnload(); const workspaceMode = useSelector(selectWorkspaceMode); useGitHubInstallationGuard(); diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 7def893..42e6b10 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -137,7 +137,9 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } if (!isSuccess) { return; } - if (fetchedDoc?.filePath === storedContentPath) return; + const sameDocument = fetchedDoc?.filePath === storedContentPath; + const sameContent = normalizeEOL(fetchedDoc?.content ?? '') === normalizeEOL(storedContent); + if (sameDocument && sameContent) return; const tab = curTabs.find(({ ident }) => ident === curDocPath); const ctx = crepeEditorRef.current?.get()?.ctx; diff --git a/client/src/components/GitBox/GitHubChangesBox.tsx b/client/src/components/GitBox/GitHubChangesBox.tsx index 2d1cc18..5c31792 100644 --- a/client/src/components/GitBox/GitHubChangesBox.tsx +++ b/client/src/components/GitBox/GitHubChangesBox.tsx @@ -11,10 +11,9 @@ import type { MutationResult, WorkspaceChange } from '@markdown-editor/github-wo import { CommitMsgBox } from '@/components/GitBox/CommitMsgBox'; import { Icon } from '@/components/Icon/Icon'; -import { githubApi, usePublishGitHubWorkspaceMutation } from '@/redux-api/github'; +import { usePublishGitHubWorkspaceMutation } from '@/redux-api/github'; import { selectGitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; -import { store } from '@/store'; -import { getVisibleGitHubWorkspaceChanges, getVisibleLogicalPath } from '@/utils/githubWorkspace'; +import { getVisibleLogicalPath } from '@/utils/githubWorkspace'; import { githubWorkspaceStore, useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import { useCurPath } from '@/utils/hooks/docHooks'; import { useDeleteTab, useRenameTabs, useSaveDoc } from '@/utils/hooks/reduxHooks'; @@ -97,8 +96,8 @@ export const GitHubChangesBox: FC = () => { const [publishBody, setPublishBody] = useState(''); const [publishing, setPublishing] = useState(false); const [publish] = usePublishGitHubWorkspaceMutation(); - const workingChanges = getVisibleGitHubWorkspaceChanges(workspace.workingChanges); - const stagedChanges = getVisibleGitHubWorkspaceChanges(workspace.stagedChanges); + const unstagedChanges = workspace.unstagedChanges; + const stagedChanges = workspace.stagedChanges; const applyPathMappings = (result: MutationResult) => { const operations = result.pathMappings @@ -135,7 +134,7 @@ export const GitHubChangesBox: FC = () => { }; const publishChanges = async () => { - if (publishing || !stagedChanges.length || workspace.remoteStale) return; + if (publishing || !stagedChanges.length) return; if (!publishTitle.trim()) { Toast.warn('Commit title cannot be blank'); return; @@ -152,65 +151,37 @@ export const GitHubChangesBox: FC = () => { } catch (error) { if (token) githubWorkspaceStore.abortPublish(token); const message = (error as { message?: string }).message ?? 'Failed to publish to GitHub'; - if (message.includes('changed on GitHub')) { - try { - const remote = await store - .dispatch(githubApi.endpoints.loadGitHubWorkspace.initiate(config, { forceRefetch: true })) - .unwrap(); - await githubWorkspaceStore.attachRemote(remote); - } catch { - // Keep the original publish error; another refresh can retry the stale check. - } - } Toast.error(message); } finally { setPublishing(false); } }; - const refreshAndRebase = async () => { - try { - const remote = await store - .dispatch(githubApi.endpoints.loadGitHubWorkspace.initiate(config, { forceRefetch: true })) - .unwrap(); - if (remote.baseCommitSha === workspace.baseCommitSha) { - Toast('Already up to date'); - return; - } - const result = await githubWorkspaceStore.rebase(remote); - if (result.conflicts.length) { - Toast.error(`Cannot rebase because these paths changed remotely: ${result.conflicts.join(', ')}`); - return; - } - Toast('Local changes rebased onto the latest branch'); - } catch (error) { - Toast.error((error as { message?: string }).message ?? 'Failed to refresh GitHub workspace'); - } - }; - const discardWorkingChanges = async () => { if (!(await confirm({ message: 'Discard every working-space change?' }))) return; - const discardedChanges = workspace.workingChanges; + const discardedChanges = workspace.unstagedChanges; const groupIds = [...new Set(discardedChanges.map((change) => change.groupId))]; const result = await githubWorkspaceStore.restoreWorking(groupIds); applyPathMappings(result); await closeDiscardedUntrackedTabs(discardedChanges); }; + const discardWorkingChange = async (groupId: string) => { + if (!(await confirm({ message: 'Are you sure to restore?' }))) return; + const discardedChanges = workspace.unstagedChanges.filter((change) => change.groupId === groupId); + const result = await githubWorkspaceStore.restoreWorking([groupId]); + applyPathMappings(result); + await closeDiscardedUntrackedTabs(discardedChanges); + }; + return (
    - void refreshAndRebase()} - /> { setPublishTitle(''); setPublishBody(''); @@ -242,7 +213,7 @@ export const GitHubChangesBox: FC = () => { label="Publish" size="small" loading={publishing} - disabled={publishing || workspace.remoteStale || !stagedChanges.length || !publishTitle.trim()} + disabled={publishing || !stagedChanges.length || !publishTitle.trim()} onClick={() => void publishChanges()} />
    @@ -256,9 +227,6 @@ export const GitHubChangesBox: FC = () => { /> )} - {workspace.remoteStale && ( -
    The remote branch changed. Refresh and rebase first.
    - )}
    Staged
    @@ -289,7 +257,7 @@ export const GitHubChangesBox: FC = () => {
    -
    Working Space
    +
    Changes
    { size="18px" className="op-icon" toolTipContent="Discard all" - disabled={!workingChanges.length || workspace.publishing} + disabled={!unstagedChanges.length || workspace.publishing} onClick={() => void discardWorkingChanges()} /> { size="18px" className="op-icon" toolTipContent="Stage all" - disabled={!workingChanges.length || workspace.publishing} + disabled={!unstagedChanges.length || workspace.publishing} onClick={() => void githubWorkspaceStore.stageAll()} />
    void githubWorkspaceStore.stage([id])} onOpen={(change) => { openFile(change.path); }} - onRevert={(id) => { - const discardedChanges = workspace.workingChanges.filter((change) => change.groupId === id); - void githubWorkspaceStore.restoreWorking([id]).then(async (result) => { - applyPathMappings(result); - await closeDiscardedUntrackedTabs(discardedChanges); - }); - }} + onRevert={(id) => void discardWorkingChange(id)} />
    diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx index d799b9b..9546118 100644 --- a/client/src/components/Menu/Menu.tsx +++ b/client/src/components/Menu/Menu.tsx @@ -28,6 +28,8 @@ import { normalizePath, scrollToView, waitAndCheck, denormalizePath } from '@/ut import './Menu.scss'; +const MENU_TREE_ID = 'treeId'; + export const Menu: FC = () => { const tree = useRef(null); const treeEnvRef = useRef(null); @@ -36,7 +38,7 @@ export const Menu: FC = () => { const [isEnterMenu, setIsEnterMenu] = useState(false); const [isCreatingFirstDoc, setIsCreatingFirstDoc] = useState(false); - const { data: docRootItems = [], isFetching, isSuccess, isError } = useWorkspaceRootItemsQuery(); + const { data: docRootItems = [], dataVersion, isFetching, isSuccess, isError } = useWorkspaceRootItemsQuery(); const updateSubDocItems = useUpdateSubDocItems(); const { contentIdent: contentPath } = useSelector(selectCurDoc); @@ -77,7 +79,7 @@ export const Menu: FC = () => { }; return treeData; }, root); - }, [docRootItems]); + }, [docRootItems, dataVersion]); const treeDataProvider = useMemo(() => new StaticTreeDataProvider(renderData), [renderData]); const createFirstDoc = () => { @@ -101,6 +103,29 @@ export const Menu: FC = () => { void treeDataProvider.onDidChangeTreeDataEmitter.emit(['root']); }, [treeDataProvider]); + useEffect(() => { + let cancelled = false; + const refreshExpandedFolders = async () => { + const expandedItems = treeEnvRef.current?.viewState[MENU_TREE_ID]?.expandedItems ?? []; + const shallowestFirst = [...expandedItems].sort( + (left, right) => denormalizePath(String(left)).length - denormalizePath(String(right)).length, + ); + + // Parent folders must load before nested expanded folders can be found in the new provider. + for (const itemIndex of shallowestFirst) { + if (cancelled) return; + const item = renderData[itemIndex]; + if (!item?.isFolder) continue; + await updateSubDocItems(item, renderData, treeDataProvider); + } + }; + + void refreshExpandedFolders(); + return () => { + cancelled = true; + }; + }, [dataVersion, renderData, treeDataProvider, updateSubDocItems]); + useEffect(() => { if (contentPath) { const fn = async () => { @@ -241,7 +266,7 @@ export const Menu: FC = () => { return Boolean(targetTreeItem.isFolder); }} > - +
    diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index d14d56a..54404a7 100644 --- a/client/src/components/Menu/operations.tsx +++ b/client/src/components/Menu/operations.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import { QueryStatus } from '@reduxjs/toolkit/query'; import { InputText } from 'primereact/inputtext'; -import { FC, useContext, useEffect, useRef, useState, ReactNode } from 'react'; +import { FC, useCallback, useContext, useEffect, useRef, useState, ReactNode } from 'react'; import { DraggingPosition, StaticTreeDataProvider, TreeItem, TreeItemIndex } from 'react-complex-tree'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; @@ -52,37 +52,40 @@ export function deleteSubDocItem( export const useUpdateSubDocItems = () => { const [getDocSubItems] = useLazyWorkspaceSubItemsQuery(); - return async ( - parentItem: TreeItem, - treeData: Record>, - provider: StaticTreeDataProvider, - ) => { - const currentParentItem = treeData[parentItem.index]; - if (!currentParentItem) return; - - const { data: newSubItems, status } = await getDocSubItems({ - folderDocPath: currentParentItem.data.path.join('/'), - }); - if (status !== QueryStatus.fulfilled) { - Toast.error('Failed to get sub doc items'); - return; - } + return useCallback( + async ( + parentItem: TreeItem, + treeData: Record>, + provider: StaticTreeDataProvider, + ) => { + const currentParentItem = treeData[parentItem.index]; + if (!currentParentItem) return; + + const { data: newSubItems, status } = await getDocSubItems({ + folderDocPath: currentParentItem.data.path.join('/'), + }); + if (status !== QueryStatus.fulfilled) { + Toast.error('Failed to get sub doc items'); + return; + } - newSubItems.forEach(({ id, name, isFile, path }) => { - const idx = normalizePath(path); - if (treeData[idx]) return; - treeData[idx] = { - index: idx, - canMove: true, - isFolder: !isFile, - children: [], - canRename: true, - data: { path, id, name, parentIdx: currentParentItem.index }, - }; - }); - currentParentItem.children = newSubItems.map((d) => normalizePath(d.path)); - await provider.onDidChangeTreeDataEmitter.emit([currentParentItem.index, ...currentParentItem.children]); - }; + newSubItems.forEach(({ id, name, isFile, path }) => { + const idx = normalizePath(path); + if (treeData[idx]) return; + treeData[idx] = { + index: idx, + canMove: true, + isFolder: !isFile, + children: [], + canRename: true, + data: { path, id, name, parentIdx: currentParentItem.index }, + }; + }); + currentParentItem.children = newSubItems.map((d) => normalizePath(d.path)); + await provider.onDidChangeTreeDataEmitter.emit([currentParentItem.index, ...currentParentItem.children]); + }, + [getDocSubItems], + ); }; export const useNewDocItem = () => { diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index c21b7ec..ef2c7f0 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -21,7 +21,6 @@ import { clearAllDrafts } from '@/redux-feature/draftsSlice'; import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { updateGlobalOpts, selectGlobalOpts, selectServerStatus, ServerStatus } from '@/redux-feature/globalOptsSlice'; import ErrorBoundary from '@/utils/ErrorBoundary/ErrorBoundary'; -import { getVisibleGitHubWorkspaceChanges } from '@/utils/githubWorkspace'; import { useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import Toast from '@/utils/Toast'; import { isEqual, nextTick } from '@/utils/utils'; @@ -44,9 +43,7 @@ export const Sidebar: FC = () => { }); const [updateSettings] = useUpdateSettingsMutation(); const githubChangeCount = - workspaceMode === 'github' - ? getVisibleGitHubWorkspaceChanges([...githubWorkspace.workingChanges, ...githubWorkspace.stagedChanges]).length - : 0; + workspaceMode === 'github' ? [...githubWorkspace.unstagedChanges, ...githubWorkspace.stagedChanges].length : 0; const localChangeCount = isServerNotConnected ? 0 : [...(localGitStatus?.workspace ?? []), ...(localGitStatus?.staged ?? [])].length; diff --git a/client/src/constants.ts b/client/src/constants.ts index f9c42f6..20b746d 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -12,5 +12,4 @@ export const DEFAULT_IGNORE_DIRS = ['imgs', 'node_modules', 'dist']; export const GITHUB_WORKSPACE_DATABASE_NAME = 'markdown-editor-github-workspaces'; export const GITHUB_WORKSPACE_STORE_NAME = 'workspaces-v1'; -export const GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER = '.gitkeep'; export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json'; diff --git a/client/src/redux-api/git.ts b/client/src/redux-api/git.ts index 237adc6..4e9bb3d 100644 --- a/client/src/redux-api/git.ts +++ b/client/src/redux-api/git.ts @@ -52,7 +52,7 @@ const gitApi = docsApi.injectEndpoints({ method: 'POST', body: restoreBody, }), - invalidatesTags: () => ['GitStatus', 'Menu', 'Article'], + invalidatesTags: (_result, _error, { staged }) => (staged ? ['GitStatus'] : ['GitStatus', 'Menu', 'Article']), transformResponse, transformErrorResponse, }), diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts index 2707bcb..e25d403 100644 --- a/client/src/redux-api/github.ts +++ b/client/src/redux-api/github.ts @@ -14,7 +14,7 @@ import type { WorkspaceDescriptor, } from '@markdown-editor/github-workspace'; -import { DEFAULT_IGNORE_DIRS, GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, WORKSPACE_SETTINGS_PATH } from '@/constants'; +import { DEFAULT_IGNORE_DIRS, WORKSPACE_SETTINGS_PATH } from '@/constants'; import { getGitHubAccessToken } from '@/utils/hooks/githubAuthHooks'; export interface GitHubRepositoryOption { @@ -194,17 +194,6 @@ const initializeWorkspace = async ( sha: null, content: settingsContent, }, - ...(docsRoot - ? [ - { - path: `${docsRoot}/${GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER}`, - mode: GitMode.File, - type: 'blob' as const, - sha: null, - content: '', - }, - ] - : []), ], message: `${isInitializing ? 'Initialize' : 'Update'} Markdown Editor workspace`, }); diff --git a/client/src/utils/githubWorkspace.ts b/client/src/utils/githubWorkspace.ts index 63fe0f3..890de09 100644 --- a/client/src/utils/githubWorkspace.ts +++ b/client/src/utils/githubWorkspace.ts @@ -1,15 +1,11 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ /* eslint-disable no-control-regex */ -import type { WorkspaceChange, WorkspaceDescriptor } from '@markdown-editor/github-workspace'; +import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; -import { GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER } from '@/constants'; import { denormalizePath, normalizePath } from '@/utils/utils'; const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); -export const getVisibleGitHubWorkspaceChanges = (changes: WorkspaceChange[]) => - changes.filter((change) => !change.path.endsWith(`/${GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER}`)); - export const getRepositoryDocPath = (config: WorkspaceDescriptor, logicalPath: string, isFile: boolean) => { const relative = denormalizePath(logicalPath).filter(Boolean).join('/'); const path = [cleanPath(config.docsRoot), relative].filter(Boolean).join('/'); diff --git a/client/src/utils/githubWorkspaceRuntime.ts b/client/src/utils/githubWorkspaceRuntime.ts index 07f1e0c..eef1a33 100644 --- a/client/src/utils/githubWorkspaceRuntime.ts +++ b/client/src/utils/githubWorkspaceRuntime.ts @@ -5,12 +5,7 @@ import { } from '@markdown-editor/github-workspace'; import { useSyncExternalStore } from 'react'; -import { - GITHUB_WORKSPACE_DATABASE_NAME, - GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, - GITHUB_WORKSPACE_STORE_NAME, - WORKSPACE_SETTINGS_PATH, -} from '@/constants'; +import { GITHUB_WORKSPACE_DATABASE_NAME, GITHUB_WORKSPACE_STORE_NAME, WORKSPACE_SETTINGS_PATH } from '@/constants'; export const githubWorkspaceStore = new GitWorkspaceStore( new IndexedDbWorkspacePersistence({ @@ -18,8 +13,7 @@ export const githubWorkspaceStore = new GitWorkspaceStore( storeName: GITHUB_WORKSPACE_STORE_NAME, }), { - emptyDirectoryMarker: GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER, - managedPaths: [WORKSPACE_SETTINGS_PATH], + protectedPaths: [WORKSPACE_SETTINGS_PATH], }, ); diff --git a/client/src/utils/hooks/reduxHooks.ts b/client/src/utils/hooks/reduxHooks.ts index dd8e5ea..5c329b8 100644 --- a/client/src/utils/hooks/reduxHooks.ts +++ b/client/src/utils/hooks/reduxHooks.ts @@ -28,6 +28,7 @@ import { ServerStatus, } from '@/redux-feature/globalOptsSlice'; import { store } from '@/store'; +import { useGitHubWorkspaceSnapshot } from '@/utils/githubWorkspaceRuntime'; import { useUpdateWorkspaceDoc } from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; @@ -276,19 +277,22 @@ export function useCheckServer(enabled = true) { return res; } -export function useWarnUnsavedOnUnload() { +export function useWarnBeforeUnload() { const hasDrafts = useSelector((state: RootState) => Object.keys(state.drafts).length > 0); + const workspace = useGitHubWorkspaceSnapshot(); + const hasUnpublishedChanges = workspace.unstagedChanges.length > 0 || workspace.stagedChanges.length > 0; + const shouldWarn = hasDrafts || hasUnpublishedChanges; useEffect(() => { - if (!hasDrafts) return; + if (!shouldWarn) return; - const handler = (e: BeforeUnloadEvent) => { - e.preventDefault(); + const handler = (event: BeforeUnloadEvent) => { + event.preventDefault(); }; window.addEventListener('beforeunload', handler); return () => { window.removeEventListener('beforeunload', handler); }; - }, [hasDrafts]); + }, [shouldWarn]); } diff --git a/client/src/utils/hooks/workspaceHooks.ts b/client/src/utils/hooks/workspaceHooks.ts index 52d5638..3a74017 100644 --- a/client/src/utils/hooks/workspaceHooks.ts +++ b/client/src/utils/hooks/workspaceHooks.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import { QueryStatus, skipToken } from '@reduxjs/toolkit/query'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useSelector } from 'react-redux'; import type { @@ -16,7 +16,6 @@ import type { import type { RootState } from '@/store'; import type { WorkspaceEntry } from '@markdown-editor/github-workspace'; -import { GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER } from '@/constants'; import { useCopyCutDocMutation, useCreateDocMutation, @@ -44,12 +43,7 @@ const isConfigured = (config: GitHubWorkspaceConfig) => Boolean(config.owner && const isHiddenMenuEntry = (config: GitHubWorkspaceConfig, entry: WorkspaceEntry) => { const name = entry.path.split('/').at(-1) ?? ''; - return ( - name === GITHUB_WORKSPACE_EMPTY_DIRECTORY_MARKER || - name === '_assets' || - name.startsWith('.') || - (entry.kind === 'directory' && config.ignoreDirs.includes(name)) - ); + return name === '_assets' || name.startsWith('.') || (entry.kind === 'directory' && config.ignoreDirs.includes(name)); }; const toDocTreeNode = (config: GitHubWorkspaceConfig, entry: WorkspaceEntry): DocTreeNode | null => { @@ -93,7 +87,7 @@ export const useGitHubWorkspaceSync = () => { }, [configured, githubAccessToken, workspace.config, workspace.mode]); const query = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.hydrated + workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.persistenceLoaded ? workspace.config : skipToken, { refetchOnMountOrArgChange: true }, @@ -119,22 +113,36 @@ export const useWorkspaceRootItemsQuery = () => { ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) : false; const githubQuery = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.hydrated + workspace.mode === 'github' && configured && githubAccessToken && activeWorkspace && snapshot.persistenceLoaded ? workspace.config : skipToken, { refetchOnMountOrArgChange: true }, ); const data = useMemo( () => - workspace.mode === 'github' && githubAccessToken && activeWorkspace && snapshot.hydrated + workspace.mode === 'github' && githubAccessToken && activeWorkspace && snapshot.persistenceLoaded ? listGitHubSubItems(workspace.config) : [], - [activeWorkspace, githubAccessToken, snapshot.revision, snapshot.hydrated, workspace.config, workspace.mode], + [ + activeWorkspace, + githubAccessToken, + snapshot.workspaceVersion, + snapshot.persistenceLoaded, + workspace.config, + workspace.mode, + ], ); - if (workspace.mode === 'local') return localQuery; + if (workspace.mode === 'local') { + return { + ...localQuery, + // refresh menu if changed + dataVersion: localQuery.fulfilledTimeStamp ?? 0, + }; + } if (!githubAccessToken) { return { data: [], + dataVersion: 0, error: { message: 'Sign in to GitHub to use this workspace.' }, isError: true, isFetching: false, @@ -144,12 +152,15 @@ export const useWorkspaceRootItemsQuery = () => { } return { data, + dataVersion: snapshot.workspaceVersion, error: githubQuery.error, isError: githubQuery.isError, isFetching: githubQuery.isFetching || - (configured && (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) && !githubQuery.isError), - isSuccess: (activeWorkspace && snapshot.hydrated && Boolean(snapshot.baseCommitSha)) || !configured, + (configured && + (!activeWorkspace || !snapshot.persistenceLoaded || !snapshot.baseCommitSha) && + !githubQuery.isError), + isSuccess: (activeWorkspace && snapshot.persistenceLoaded && Boolean(snapshot.baseCommitSha)) || !configured, refetch: async () => ({ data }), }; }; @@ -186,11 +197,12 @@ export const useWorkspaceDocQuery = (logicalPath: string) => { ? getGitHubWorkspaceKey(snapshot.descriptor) === getGitHubWorkspaceKey(workspace.config) : false; const workspaceQuery = useLoadGitHubWorkspaceQuery( - workspace.mode === 'github' && configured && activeWorkspace && snapshot.hydrated ? workspace.config : skipToken, + workspace.mode === 'github' && configured && activeWorkspace && snapshot.persistenceLoaded + ? workspace.config + : skipToken, ); const repositoryPath = getRepositoryDocPath(workspace.config, logicalPath, true); - const resolvedPath = githubWorkspaceStore.resolveMovedPath(repositoryPath); - const source = githubWorkspaceStore.getFileSource(resolvedPath); + const source = githubWorkspaceStore.getFileSource(repositoryPath); const blobQuery = useGetGitHubBlobQuery( workspace.mode === 'github' && source?.kind === 'remote' && source.sha ? { owner: workspace.config.owner, repo: workspace.config.repo, sha: source.sha } @@ -201,21 +213,30 @@ export const useWorkspaceDocQuery = (logicalPath: string) => { () => (githubContent === undefined ? undefined : getDefaultArticle(logicalPath, githubContent)), [githubContent, logicalPath], ); + const githubDocumentKey = `${getGitHubWorkspaceKey(workspace.config)}:${logicalPath}`; + const lastLoadedGithubArticleRef = useRef<{ key: string; article: Article } | undefined>(undefined); + if (githubArticle) lastLoadedGithubArticleRef.current = { key: githubDocumentKey, article: githubArticle }; + const cachedGithubArticle = + lastLoadedGithubArticleRef.current?.key === githubDocumentKey + ? lastLoadedGithubArticleRef.current.article + : undefined; + // Keep the last loaded document while a file operation updates its tab or route. + const displayedGithubArticle = githubArticle ?? cachedGithubArticle; const missingDocumentError = useMemo(() => new Error(`The file ${logicalPath} does not exist.`), [logicalPath]); if (workspace.mode === 'local') return localQuery; - if (!activeWorkspace || !snapshot.hydrated || !snapshot.baseCommitSha) { + if (!activeWorkspace || !snapshot.persistenceLoaded || !snapshot.baseCommitSha) { return { data: undefined, error: workspaceQuery.error, isSuccess: false, - isFetching: !activeWorkspace || !snapshot.hydrated || !workspaceQuery.isError, + isFetching: !activeWorkspace || !snapshot.persistenceLoaded || !workspaceQuery.isError, }; } return { - data: githubArticle, - error: source ? blobQuery.error : missingDocumentError, - isSuccess: githubArticle !== undefined, + data: displayedGithubArticle, + error: source || cachedGithubArticle ? blobQuery.error : missingDocumentError, + isSuccess: displayedGithubArticle !== undefined, isFetching: Boolean(source && githubArticle === undefined && blobQuery.isFetching), }; }; diff --git a/packages/github-workspace/README.md b/packages/github-workspace/README.md new file mode 100644 index 0000000..6267d96 --- /dev/null +++ b/packages/github-workspace/README.md @@ -0,0 +1,108 @@ +# GitHub Workspace + +Browser-side Git workspace state manager. It keeps file changes in IndexedDB and publishes staged changes through the GitHub API. + +## State model + +The workspace keeps three complete trees: + +```text +baseTree Remote commit currently used as the base +stagedTree Files included in the next publish +workingTree Files and folders currently shown in the editor +``` + +Git status is derived from differences between these trees: + +```ts +unstagedChanges = diffTrees(stagedTree, workingTree, 'UNTRACKED'); +stagedChanges = diffTrees(baseTree, stagedTree, 'ADDED'); +``` + +`workingTree` is therefore the final workspace. It already contains both staged and unstaged edits. + +## Status and tree transformations + +`diffTrees(from, to, addedStatus)` converts two trees into file changes: + +- missing in `from`, present in `to` → `ADDED` or `UNTRACKED` +- present in both with different content or Git data → `MODIFIED` +- present in `from`, missing in `to` → `DELETED` +- identical in both → no change + +Directories are ignored because Git tracks files, not directories. + +`applyChangesToTree(baseTree, changes)` performs the reverse transformation. It clones the base, applies additions, +updates, and deletions, then rebuilds parent directories. + +Persistence uses both directions: + +```ts +// Save +stagedChanges = getStagedChanges(baseTree, stagedTree); +unstagedChanges = getUnstagedChanges(stagedTree, workingTree); + +// Load +stagedTree = applyChangesToTree(baseTree, stagedChanges); +workingTree = applyChangesToTree(stagedTree, unstagedChanges); +``` + +## File operations + +Operations mutate `workingTree`. Status is recalculated from the trees instead of being updated manually. + +| Operation | Tree change | Resulting unstaged status | +| ------------------- | --------------------------------- | ------------------------- | +| Create file | Add to `workingTree` | `UNTRACKED` | +| Update tracked file | Replace its content | `MODIFIED` | +| Delete tracked file | Remove from `workingTree` | `DELETED` | +| Move file | Delete source and add destination | grouped delete and add | +| Copy file | Add destination | `UNTRACKED` | +| Create empty folder | Store locally in IndexedDB | no Git status | + +Each operation receives `operationMetadata`. Its `groupId` keeps related paths together, so a move or directory +operation can be staged, restored, or displayed as one action. + +### Reconciliation examples + +Create, update, then delete before staging: + +```text +create note.md → UNTRACKED +update note.md → UNTRACKED +delete note.md → no change (workingTree matches stagedTree again) +``` + +Stage a file, then edit it again: + +```text +create note.md → unstaged: UNTRACKED +stage note.md → staged: ADDED +update note.md → staged: ADDED, unstaged: MODIFIED +``` + +Stage a new file, then delete it: + +```text +stagedTree contains note.md → staged: ADDED +workingTree removes note.md → unstaged: DELETED +``` + +Publishing creates the staged file first. The remaining unstaged deletion is then replayed onto the new base. + +## Staging and publishing + +- `stageChanges()` copies selected paths from `workingTree` to `stagedTree`. +- `unstageChanges()` copies selected paths from `baseTree` to `stagedTree`. +- `restoreWorkingChanges()` copies selected paths from `stagedTree` to `workingTree`. +- `getPublishEntries()` converts `baseTree → stagedTree` changes into GitHub tree entries. +- After publishing, the remote tree becomes both `baseTree` and `stagedTree`; remaining unstaged changes are replayed + onto `workingTree`. + +## Source map + +- `src/index.ts` — public store and operation orchestration +- `src/workspace-operations.ts` — file, folder, staging, and restore mutations +- `src/git-status.ts` — tree diffing, change application, and publish entries +- `src/persistence.ts` — IndexedDB storage +- `src/types.ts` — public state and operation types diff --git a/packages/github-workspace/src/git-status.ts b/packages/github-workspace/src/git-status.ts index 41384f1..4884f58 100644 --- a/packages/github-workspace/src/git-status.ts +++ b/packages/github-workspace/src/git-status.ts @@ -1,39 +1,40 @@ /* eslint-disable @typescript-eslint/no-dynamic-delete */ import { GitMode } from './types'; -import { cleanPath, makeMetadata, parentPath } from './utils'; +import { cleanPath, createOperationMetadata } from './utils'; import type { - ChangeMetadata, + OperationMetadata, PathMapping, + PublishTreeEntry, RemoteTreeEntry, TreeState, WorkspaceChange, - WorkspaceConventions, WorkspaceDescriptor, WorkspaceEntry, WorkspaceStatus, - PublishTreeEntry, } from './types'; -export const emptyTree = (): TreeState => ({ entries: {}, deletedMetadata: {} }); +export const emptyTree = (): TreeState => ({ entries: {}, deletedOperationMetadata: {} }); -export const cloneEntry = (entry: WorkspaceEntry): WorkspaceEntry => ({ - ...entry, - metadata: entry.metadata ? { ...entry.metadata, scopePaths: [...entry.metadata.scopePaths] } : undefined, +export const cloneOperationMetadata = (operationMetadata: OperationMetadata): OperationMetadata => ({ + groupId: operationMetadata.groupId, + label: operationMetadata.label, + scopePaths: [...operationMetadata.scopePaths], }); -export const cloneMetadata = (metadata: ChangeMetadata): ChangeMetadata => ({ - groupId: metadata.groupId, - label: metadata.label, - scopePaths: [...metadata.scopePaths], -}); +export const cloneEntry = (entry: WorkspaceEntry): WorkspaceEntry => { + return { + ...entry, + operationMetadata: entry.operationMetadata ? cloneOperationMetadata(entry.operationMetadata) : undefined, + }; +}; export const cloneTree = (tree: TreeState): TreeState => ({ entries: Object.fromEntries(Object.entries(tree.entries).map(([path, entry]) => [path, cloneEntry(entry)])), - deletedMetadata: Object.fromEntries( - Object.entries(tree.deletedMetadata).map(([path, metadata]) => [ + deletedOperationMetadata: Object.fromEntries( + Object.entries(tree.deletedOperationMetadata).map(([path, operationMetadata]) => [ path, - { ...metadata, scopePaths: [...metadata.scopePaths] }, + cloneOperationMetadata(operationMetadata), ]), ), }); @@ -88,80 +89,64 @@ const sameEntry = (left?: WorkspaceEntry, right?: WorkspaceEntry) => left.content === right.content, ); -const entryMetadata = (tree: TreeState, path: string, entry?: WorkspaceEntry) => - entry?.metadata ?? tree.deletedMetadata[path]; +const getOperationMetadata = (tree: TreeState, path: string, entry?: WorkspaceEntry) => + entry?.operationMetadata ?? tree.deletedOperationMetadata[path]; -const isEmptyDirectory = (tree: TreeState, path: string, emptyDirectoryMarker: string) => - !Object.values(tree.entries).some( - (entry) => parentPath(entry.path) === path && entry.path !== `${path}/${emptyDirectoryMarker}`, - ); - -export const treeToGitStatus = ( - from: TreeState, - to: TreeState, - addedStatus: 'ADDED' | 'UNTRACKED', - conventions: WorkspaceConventions, -): WorkspaceChange[] => { +/** Returns file changes only. Git does not track directories. */ +export const diffTrees = (from: TreeState, to: TreeState, addedStatus: 'ADDED' | 'UNTRACKED'): WorkspaceChange[] => { const paths = new Set([...Object.keys(from.entries), ...Object.keys(to.entries)]); return [...paths] .sort((left, right) => left.localeCompare(right)) .flatMap((path) => { const oldEntry = from.entries[path]; const newEntry = to.entries[path]; - if (sameEntry(oldEntry, newEntry)) return []; - // Directories are a UI concept rather than Git tree leaves. Only empty-directory changes need their own status. - if ( - (newEntry?.kind === 'directory' && - !oldEntry && - !isEmptyDirectory(to, path, conventions.emptyDirectoryMarker)) || - (oldEntry?.kind === 'directory' && !newEntry && !isEmptyDirectory(from, path, conventions.emptyDirectoryMarker)) - ) { - return []; - } - const metadata = - entryMetadata(to, path, newEntry) ?? makeMetadata(`${newEntry ? 'Update' : 'Delete'} ${path}`, [path]); + if (sameEntry(oldEntry, newEntry) || (newEntry ?? oldEntry)?.kind === 'directory') return []; + const operationMetadata = + getOperationMetadata(to, path, newEntry) ?? + createOperationMetadata(`${newEntry ? 'Update' : 'Delete'} ${path}`, [path]); const status: WorkspaceStatus = !oldEntry ? addedStatus : !newEntry ? 'DELETED' : 'MODIFIED'; return [ { id: `${status}:${path}`, - groupId: metadata.groupId, - label: metadata.label, + groupId: operationMetadata.groupId, + label: operationMetadata.label, status, path, kind: (newEntry ?? oldEntry).kind, oldEntry: oldEntry ? cloneEntry(oldEntry) : null, newEntry: newEntry ? cloneEntry(newEntry) : null, - scopePaths: [...metadata.scopePaths], + scopePaths: [...operationMetadata.scopePaths], }, ]; }); }; -export const getWorkingChanges = (index: TreeState, working: TreeState, conventions: WorkspaceConventions) => - treeToGitStatus(index, working, 'UNTRACKED', conventions); +export const getUnstagedChanges = (stagedTree: TreeState, workingTree: TreeState) => + diffTrees(stagedTree, workingTree, 'UNTRACKED'); -export const getStagedChanges = (base: TreeState, index: TreeState, conventions: WorkspaceConventions) => - treeToGitStatus(base, index, 'ADDED', conventions); +export const getStagedChanges = (baseTree: TreeState, stagedTree: TreeState) => + diffTrees(baseTree, stagedTree, 'ADDED'); -export const gitStatusToTree = (base: TreeState, changes: WorkspaceChange[], root = '') => { - const result = cloneTree(base); +/** Applies file changes and rebuilds their parent directories. */ +export const applyChangesToTree = (baseTree: TreeState, changes: WorkspaceChange[], root = '') => { + const result = cloneTree(baseTree); changes.forEach((change) => { + if ((change.newEntry ?? change.oldEntry)?.kind === 'directory') return; if (change.newEntry) { - result.entries[change.path] = cloneEntry(change.newEntry); - delete result.deletedMetadata[change.path]; + const entry = cloneEntry(change.newEntry); + entry.operationMetadata ??= cloneOperationMetadata(change); + result.entries[change.path] = entry; + delete result.deletedOperationMetadata[change.path]; } else { delete result.entries[change.path]; - result.deletedMetadata[change.path] = cloneMetadata(change); + result.deletedOperationMetadata[change.path] = cloneOperationMetadata(change); } }); Object.values(result.entries) .filter((entry) => entry.kind === 'file') .forEach((entry) => { getDirectoryPaths(entry.path, cleanPath(root)).forEach((path) => { - result.entries[path] ??= { - ...createDirectoryEntry(path), - metadata: entry.metadata ? cloneMetadata(entry.metadata) : undefined, - }; + result.entries[path] ??= createDirectoryEntry(path); }); }); Object.values(result.entries) @@ -173,53 +158,59 @@ export const gitStatusToTree = (base: TreeState, changes: WorkspaceChange[], roo return result; }; -export const stagedChangesToPublishEntries = ( - base: TreeState, - index: TreeState, - root: string, - conventions: WorkspaceConventions, -): PublishTreeEntry[] => - treeToGitStatus(base, gitStatusToTree(base, getStagedChanges(base, index, conventions), root), 'ADDED', conventions) - .filter((change) => change.kind === 'file') - .map((change) => { - if (!change.newEntry) { - return { - path: change.path, - mode: change.oldEntry?.mode ?? GitMode.File, - type: change.oldEntry?.type === 'commit' ? 'commit' : 'blob', - sha: null, - }; - } +/** Keeps explicit empty folders in browser storage without adding them to Git. */ +export const getLocalDirectories = (tree: TreeState) => + Object.values(tree.entries) + .filter( + (entry) => + entry.kind === 'directory' && + Boolean(entry.operationMetadata) && + !Object.keys(tree.entries).some((path) => path.startsWith(`${entry.path}/`)), + ) + .map(cloneEntry); + +export const restoreLocalDirectories = (tree: TreeState, directories: WorkspaceEntry[], root = '') => { + directories.forEach((entry) => { + if (entry.kind !== 'directory' || tree.entries[entry.path]) return; + getDirectoryPaths(`${entry.path}/child`, cleanPath(root)).forEach((path) => { + tree.entries[path] ??= path === entry.path ? cloneEntry(entry) : createDirectoryEntry(path); + }); + }); +}; + +export const getPublishEntries = (baseTree: TreeState, stagedTree: TreeState): PublishTreeEntry[] => + getStagedChanges(baseTree, stagedTree).map((change) => { + if (!change.newEntry) { return { path: change.path, - mode: change.newEntry.mode, - type: change.newEntry.type === 'commit' ? 'commit' : 'blob', - sha: change.newEntry.content === undefined ? change.newEntry.sha ?? null : null, - content: change.newEntry.content, + mode: change.oldEntry?.mode ?? GitMode.File, + type: change.oldEntry?.type === 'commit' ? 'commit' : 'blob', + sha: null, }; - }); - -export const changedPaths = (left: TreeState, right: TreeState, conventions: WorkspaceConventions) => - treeToGitStatus(left, right, 'ADDED', conventions).map((change) => change.path); + } + return { + path: change.path, + mode: change.newEntry.mode, + type: change.newEntry.type === 'commit' ? 'commit' : 'blob', + sha: change.newEntry.content === undefined ? change.newEntry.sha ?? null : null, + content: change.newEntry.content, + }; + }); export const copyPathState = (source: TreeState, destination: TreeState, path: string) => { const entry = source.entries[path]; if (entry) destination.entries[path] = cloneEntry(entry); else delete destination.entries[path]; - const metadata = source.deletedMetadata[path]; - if (metadata) destination.deletedMetadata[path] = { ...metadata, scopePaths: [...metadata.scopePaths] }; - else delete destination.deletedMetadata[path]; + const operationMetadata = source.deletedOperationMetadata[path]; + if (operationMetadata) { + destination.deletedOperationMetadata[path] = cloneOperationMetadata(operationMetadata); + } else { + delete destination.deletedOperationMetadata[path]; + } }; -export const applyGitStatusToTree = ( - from: TreeState, - desired: TreeState, - onto: TreeState, - root: string, - conventions: WorkspaceConventions, -) => { - return gitStatusToTree(onto, treeToGitStatus(from, desired, 'ADDED', conventions), root); -}; +export const replayChangesOntoTree = (from: TreeState, desired: TreeState, onto: TreeState, root: string) => + applyChangesToTree(onto, diffTrees(from, desired, 'ADDED'), root); export const getPathMappings = (before: TreeState, after: TreeState) => { const beforeById = new Map(Object.values(before.entries).map((entry) => [entry.id, entry])); diff --git a/packages/github-workspace/src/index.ts b/packages/github-workspace/src/index.ts index 4dd0850..7ff450d 100644 --- a/packages/github-workspace/src/index.ts +++ b/packages/github-workspace/src/index.ts @@ -1,38 +1,34 @@ /** * Browser-side Git workspace orchestration. * - * - `base` is the immutable manifest at the selected remote commit. - * - `index` is the staged snapshot that will become the next commit. - * - `working` is the effective tree consumed by the menu and editor. - * - * Workspace operations mutate a tree, the status boundary derives/merges Git-like changes, and the persistence - * boundary stores the base plus normalized working/staged statuses before subscribers are notified. + * `baseTree` is the remote commit, `stagedTree` is the next commit, and + * `workingTree` is the version shown in the editor. */ import { WORKSPACE_SCHEMA_VERSION } from './constants'; import { - applyGitStatusToTree, - changedPaths, + applyChangesToTree, cloneTree, createBaseTree, emptyTree, - gitStatusToTree, + getLocalDirectories, getPathMappings, + getPublishEntries, getStagedChanges, - getWorkingChanges, - stagedChangesToPublishEntries, + getUnstagedChanges, + replayChangesOntoTree, + restoreLocalDirectories, } from './git-status'; import { createPersistedWorkspace } from './persistence'; -import { cleanPath, createId, getWorkspaceKey, makeMetadata, parentPath, pathsOverlap } from './utils'; +import { cleanPath, createId, createOperationMetadata, getWorkspaceKey } from './utils'; import { assertInScope, createDirectory, createFileEntry, deleteWorkspaceEntry, - getTransferMetadata, + getTransferOperation, getWorkspaceEntry, listDirectoryEntries, - resolveMovedEntryPath, restoreWorkingChanges, stageChanges, transferWorkspaceEntry, @@ -41,16 +37,15 @@ import { } from './workspace-operations'; import type { - ChangeMetadata, MutationResult, + OperationMetadata, PathMapping, PublishPlan, - RebaseResult, RemoteWorkspaceSnapshot, TreeState, WorkspaceDescriptor, - WorkspaceConventions, WorkspacePersistence, + WorkspaceRules, WorkspaceSnapshot, } from './types'; @@ -63,394 +58,431 @@ export { getWorkspaceKey } from './utils'; export class GitWorkspaceStore { private descriptor: WorkspaceDescriptor | null = null; - private key = ''; + private workspaceKey = ''; private baseCommitSha = ''; private baseTreeSha = ''; - private base = emptyTree(); - - private index = emptyTree(); + private baseTree = emptyTree(); - private working = emptyTree(); + private stagedTree = emptyTree(); - private revision = 0; + private workingTree = emptyTree(); - private hydrated = false; + private workspaceVersion = 0; - private remoteStale = false; + private persistenceLoaded = false; private publishingToken = ''; - private readonly aliases = new Map(); + private readonly movedPathAliases = new Map(); private readonly listeners = new Set<() => void>(); - private mutationQueue: Promise = Promise.resolve(); + private operationQueue: Promise = Promise.resolve(); - private snapshotCache: WorkspaceSnapshot | undefined; + private cachedSnapshot: WorkspaceSnapshot | undefined; - public constructor( - private readonly persistence: WorkspacePersistence, - private readonly conventions: WorkspaceConventions, - ) {} + /** + * Creates an inactive store with its persistence adapter and path rules. + * Call `open()` before reading or changing workspace files. + */ + public constructor(private readonly persistence: WorkspacePersistence, private readonly rules: WorkspaceRules) {} + /** + * Registers a listener for snapshot changes and returns its unsubscribe function. + * The stable function shape is compatible with React's `useSyncExternalStore()`. + */ public subscribe = (listener: () => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener); }; + /** + * Returns the current editor tree and its derived staged and unstaged changes. + * The same cached object is returned until the store notifies subscribers. + */ public getSnapshot = (): WorkspaceSnapshot => { - this.snapshotCache ??= { + this.cachedSnapshot ??= { descriptor: this.descriptor ? { ...this.descriptor } : null, baseCommitSha: this.baseCommitSha, baseTreeSha: this.baseTreeSha, - revision: this.revision, - hydrated: this.hydrated, - remoteStale: this.remoteStale, + workspaceVersion: this.workspaceVersion, + persistenceLoaded: this.persistenceLoaded, publishing: Boolean(this.publishingToken), - entries: cloneTree(this.working).entries, - workingChanges: this.getWorkingChanges(), - stagedChanges: this.getStagedChanges(), + entries: cloneTree(this.workingTree).entries, + unstagedChanges: getUnstagedChanges(this.stagedTree, this.workingTree), + stagedChanges: getStagedChanges(this.baseTree, this.stagedTree), }; - return this.snapshotCache; + return this.cachedSnapshot; }; + /** + * Opens a repository workspace and restores its trees from browser storage. + * A workspace without saved state stays empty until `attachRemote()` provides its remote tree. + */ public async open(descriptor: WorkspaceDescriptor) { - await this.mutationQueue.catch(() => undefined); + await this.operationQueue.catch(() => undefined); const key = getWorkspaceKey(descriptor); - if (this.key === key && this.hydrated) return; + if (this.workspaceKey === key && this.persistenceLoaded) return; this.descriptor = { ...descriptor, docsRoot: cleanPath(descriptor.docsRoot) }; - this.key = key; - this.hydrated = false; - this._emit(); + this.workspaceKey = key; + this.persistenceLoaded = false; + this.movedPathAliases.clear(); + this._notifySubscribers(); const persisted = await this.persistence.read(key); - if (this.key !== key) return; + if (this.workspaceKey !== key) return; if (persisted?.schemaVersion === WORKSPACE_SCHEMA_VERSION) { this.baseCommitSha = persisted.baseCommitSha; this.baseTreeSha = persisted.baseTreeSha; - this.base = cloneTree(persisted.base); - this.index = gitStatusToTree(this.base, persisted.stagedChanges, this.descriptor.docsRoot); - this.working = gitStatusToTree(this.index, persisted.workingChanges, this.descriptor.docsRoot); - this.revision = persisted.revision; - this.remoteStale = persisted.remoteStale; + this.baseTree = cloneTree(persisted.baseTree); + this.stagedTree = applyChangesToTree(this.baseTree, persisted.stagedChanges, this.descriptor.docsRoot); + this.workingTree = applyChangesToTree(this.stagedTree, persisted.unstagedChanges, this.descriptor.docsRoot); + restoreLocalDirectories(this.workingTree, persisted.localDirectories, this.descriptor.docsRoot); + this.workspaceVersion = persisted.workspaceVersion; } else { this.baseCommitSha = ''; this.baseTreeSha = ''; - this.base = emptyTree(); - this.index = emptyTree(); - this.working = emptyTree(); - this.revision = 0; - this.remoteStale = false; + this.baseTree = emptyTree(); + this.stagedTree = emptyTree(); + this.workingTree = emptyTree(); + this.workspaceVersion = 0; } - this.hydrated = true; - this._emit(); + this.persistenceLoaded = true; + this._notifySubscribers(); } + /** + * Closes the active workspace and clears its in-memory state. + * Saved browser data is kept for the next `open()` call. + */ public close() { this.descriptor = null; - this.key = ''; - this.hydrated = false; - this.aliases.clear(); - this._emit(); - } - + this.workspaceKey = ''; + this.baseCommitSha = ''; + this.baseTreeSha = ''; + this.baseTree = emptyTree(); + this.stagedTree = emptyTree(); + this.workingTree = emptyTree(); + this.workspaceVersion = 0; + this.persistenceLoaded = false; + this.movedPathAliases.clear(); + this._notifySubscribers(); + } + + /** + * Initializes or refreshes the workspace from a remote Git tree. + * Local empty folders are preserved, and a newer remote tree is ignored while file changes are unpublished. + */ public async attachRemote(snapshot: RemoteWorkspaceSnapshot) { - const expectedKey = this.key; - return this._enqueue(async () => { - if (!this.descriptor || this.key !== expectedKey) return; + const expectedKey = this.workspaceKey; + return this._enqueueOperation(async () => { + if (!this.descriptor || this.workspaceKey !== expectedKey) return; const remote = createBaseTree(this.descriptor, snapshot.entries); - const pending = this.getWorkingChanges().length > 0 || this.getStagedChanges().length > 0; + const pending = + getUnstagedChanges(this.stagedTree, this.workingTree).length > 0 || + getStagedChanges(this.baseTree, this.stagedTree).length > 0; + // A remote refresh must not replace unpublished local files. if (this.baseCommitSha && this.baseCommitSha !== snapshot.baseCommitSha && pending) { - this.remoteStale = true; - await this._persistAndEmit(); return; } - if (!pending || !this.baseCommitSha) { - this.base = remote; - this.index = cloneTree(remote); - this.working = cloneTree(remote); - } + if (pending && this.baseCommitSha) return; + const localDirectories = getLocalDirectories(this.workingTree); + this.baseTree = remote; + this.stagedTree = cloneTree(remote); + this.workingTree = cloneTree(remote); + restoreLocalDirectories(this.workingTree, localDirectories, this.descriptor.docsRoot); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; - this.remoteStale = false; - await this._persistAndEmit(); + this.workspaceVersion += 1; + await this._saveAndNotify(); }); } - public getWorkingChanges() { - return getWorkingChanges(this.index, this.working, this.conventions); - } - - public getStagedChanges() { - return getStagedChanges(this.base, this.index, this.conventions); - } - + /** + * Returns cloned direct children of a directory, with folders before files. + * Hidden-file and application-specific filtering is handled by the client. + */ public listDirectory(path: string) { - return listDirectoryEntries(this.working, path, this.conventions); - } - - public getEntry(path: string) { - return getWorkspaceEntry(this.working, this.aliases, path); + return listDirectoryEntries(this.workingTree, path); } + /** + * Returns local content for an edited file or a SHA for an unchanged remote file. + * Moved-path aliases are resolved automatically; missing paths and directories return `undefined`. + */ public getFileSource(path: string) { - const entry = this.getEntry(path); + const entry = getWorkspaceEntry(this.workingTree, this.movedPathAliases, path); if (!entry || entry.kind !== 'file') return undefined; if (entry.content !== undefined) return { kind: 'local', content: entry.content, sha: entry.sha } as const; return { kind: 'remote', sha: entry.sha } as const; } - public resolveMovedPath(path: string) { - return resolveMovedEntryPath(this.working, this.aliases, path); - } - + /** + * Creates a file in the working tree and records one operation group for it. + * The resulting file is unstaged and the updated workspace is persisted. + */ public async createFile(path: string, content = '') { - assertInScope(this.descriptor, path, this.conventions); - return this._mutate(`Create file ${path}`, [path], (tree, metadata) => { - createFileEntry(tree, this._requireDescriptor(), path, content, metadata, this.conventions); + assertInScope(this.descriptor, path, this.rules); + return this._applyOperation(`Create file ${path}`, [path], (tree, operationMetadata) => { + createFileEntry(tree, this._getOpenDescriptor(), path, content, operationMetadata); }); } + /** + * Creates a local-only directory and persists it in browser storage. + * Empty directories do not produce Git status or publish entries. + */ public async createDirectory(path: string) { - assertInScope(this.descriptor, path, this.conventions); - return this._mutate(`Create folder ${path}`, [path], (tree, metadata) => { - createDirectory(tree, this._requireDescriptor(), path, metadata, this.conventions); + assertInScope(this.descriptor, path, this.rules); + return this._applyOperation(`Create folder ${path}`, [path], (tree, operationMetadata) => { + createDirectory(tree, this._getOpenDescriptor(), path, operationMetadata); }); } + /** + * Replaces a working-tree file's content and persists the change. + * Repeated writes reuse the file's operation group so they appear as one change. + */ public async writeFile(path: string, content: string) { - assertInScope(this.descriptor, path, this.conventions); - const current = this.working.entries[path]; + assertInScope(this.descriptor, path, this.rules); + const current = this.workingTree.entries[path]; if (!current || current.kind !== 'file') return Promise.reject(new Error(`The file ${path} does not exist.`)); - const metadata = current.metadata ?? makeMetadata(`Update ${path}`, [path]); - return this._mutateWithMetadata(metadata, (tree) => { - writeFileEntry(tree, path, content, metadata); + const operationMetadata = current.operationMetadata ?? createOperationMetadata(`Update ${path}`, [path]); + return this._applyOperationWithMetadata(operationMetadata, (tree) => { + writeFileEntry(tree, path, content, operationMetadata); }); } + /** + * Removes a file or directory subtree from the working tree. + * Deleted tracked files retain operation metadata so they can be staged or restored as one action. + */ public async delete(path: string) { - assertInScope(this.descriptor, path, this.conventions); - return this._mutate(`Delete ${path}`, [path], (tree, metadata) => { - deleteWorkspaceEntry(tree, this.base, path, metadata, this.conventions); + assertInScope(this.descriptor, path, this.rules); + return this._applyOperation(`Delete ${path}`, [path], (tree, operationMetadata) => { + deleteWorkspaceEntry(tree, this.baseTree, path, operationMetadata); }); } + /** + * Moves a file or directory subtree in the working tree. + * Source deletions and destination additions share one operation group and moved paths remain resolvable. + */ public async move(source: string, destination: string) { return this._transfer(source, destination, false); } + /** + * Copies a file or directory subtree to a new working-tree path. + * Copied entries receive new IDs and are reconciled as unstaged additions. + */ public async copy(source: string, destination: string) { return this._transfer(source, destination, true); } + /** + * Stages every file path belonging to the selected operation groups. + * Selected states are copied from the working tree to the staged tree. + */ public async stage(groupIds: string[]) { - return this._enqueue(async () => { - this._assertStagingUnlocked(); - stageChanges(this.base, this.index, this.working, groupIds, this.conventions); - this.revision += 1; - await this._persistAndEmit(); - return this._result(); + return this._enqueueOperation(async () => { + this._assertCanChangeStaging(); + stageChanges(this.baseTree, this.stagedTree, this.workingTree, groupIds); + this.workspaceVersion += 1; + await this._saveAndNotify(); + return this._createMutationResult(); }); } + /** + * Stages all current file changes by replacing the staged tree with the working tree. + * Local-only directories remain excluded when Git status and publish entries are derived. + */ public async stageAll() { - return this._enqueue(async () => { - this._assertStagingUnlocked(); - this.index = cloneTree(this.working); - this.revision += 1; - await this._persistAndEmit(); - return this._result(); + return this._enqueueOperation(async () => { + this._assertCanChangeStaging(); + this.stagedTree = cloneTree(this.workingTree); + this.workspaceVersion += 1; + await this._saveAndNotify(); + return this._createMutationResult(); }); } + /** + * Removes selected operation groups from the staged set. + * Their staged paths are reset from the base tree while the working tree is left unchanged. + */ public async unstage(groupIds: string[]) { - return this._enqueue(async () => { - this._assertStagingUnlocked(); - unstageChanges(this.base, this.index, groupIds, this.conventions); - this.revision += 1; - await this._persistAndEmit(); - return this._result(); + return this._enqueueOperation(async () => { + this._assertCanChangeStaging(); + unstageChanges(this.baseTree, this.stagedTree, groupIds); + this.workspaceVersion += 1; + await this._saveAndNotify(); + return this._createMutationResult(); }); } + /** + * Discards selected unstaged operation groups from the working tree. + * Paths are restored from the staged tree and path mappings describe any reversed moves. + */ public async restoreWorking(groupIds: string[]) { - return this._enqueue(async () => { - const before = cloneTree(this.working); - restoreWorkingChanges(this.index, this.working, groupIds, this.conventions); - this.revision += 1; - await this._persistAndEmit(); - return this._result(getPathMappings(before, this.working)); - }); - } - - public async discardAll() { - return this._enqueue(async () => { - const before = cloneTree(this.working); - this.index = cloneTree(this.base); - this.working = cloneTree(this.base); - this.remoteStale = false; - this.revision += 1; - await this._persistAndEmit(); - return this._result(getPathMappings(before, this.working)); - }); - } - - public async rebase(snapshot: RemoteWorkspaceSnapshot): Promise { - return this._enqueue(async () => { - const descriptor = this._requireDescriptor(); - const remote = createBaseTree(descriptor, snapshot.entries); - const remoteChanges = changedPaths(this.base, remote, this.conventions); - const scopes = [...this.getWorkingChanges(), ...this.getStagedChanges()].flatMap((change) => change.scopePaths); - const conflicts = remoteChanges.filter((path) => scopes.some((scope) => pathsOverlap(path, scope))); - if (conflicts.length) { - this.remoteStale = true; - await this._persistAndEmit(); - return { conflicts, rebased: false }; - } - const oldBase = this.base; - this.base = remote; - this.index = applyGitStatusToTree(oldBase, this.index, remote, descriptor.docsRoot, this.conventions); - this.working = applyGitStatusToTree(oldBase, this.working, this.index, descriptor.docsRoot, this.conventions); - this.baseCommitSha = snapshot.baseCommitSha; - this.baseTreeSha = snapshot.baseTreeSha; - this.remoteStale = false; - this.revision += 1; - await this._persistAndEmit(); - return { conflicts: [], rebased: true }; + return this._enqueueOperation(async () => { + const before = cloneTree(this.workingTree); + restoreWorkingChanges(this.stagedTree, this.workingTree, groupIds); + this.workspaceVersion += 1; + const result = this._createMutationResult(getPathMappings(before, this.workingTree)); + await this._saveAndNotify(); + return result; }); } + /** + * Starts a publish transaction and returns the staged GitHub tree entries. + * The returned token identifies the transaction and staging stays locked until completion or abort. + */ public beginPublish(): PublishPlan { if (this.publishingToken) throw new Error('A publish is already in progress.'); - if (this.remoteStale) throw new Error('Rebase the GitHub workspace before publishing.'); const token = createId(); this.publishingToken = token; - this._emit(); + this._notifySubscribers(); return { token, baseCommitSha: this.baseCommitSha, baseTreeSha: this.baseTreeSha, - entries: stagedChangesToPublishEntries(this.base, this.index, this.descriptor?.docsRoot ?? '', this.conventions), + entries: getPublishEntries(this.baseTree, this.stagedTree), }; } + /** + * Completes the matching publish transaction with GitHub's new remote snapshot. + * It resets the base and staged trees, then replays remaining unstaged files and local directories. + */ public async completePublish(snapshot: RemoteWorkspaceSnapshot, token: string) { - return this._enqueue(async () => { + return this._enqueueOperation(async () => { if (token !== this.publishingToken) throw new Error('The publish transaction is no longer active.'); - const descriptor = this._requireDescriptor(); - const previousIndex = this.index; + const descriptor = this._getOpenDescriptor(); + const previousStagedTree = this.stagedTree; + const localDirectories = getLocalDirectories(this.workingTree); const nextBase = createBaseTree(descriptor, snapshot.entries); - this.base = nextBase; - this.index = cloneTree(nextBase); - this.working = applyGitStatusToTree(previousIndex, this.working, nextBase, descriptor.docsRoot, this.conventions); + this.baseTree = nextBase; + this.stagedTree = cloneTree(nextBase); + // Keep unstaged files and local-only empty folders after publishing. + this.workingTree = replayChangesOntoTree(previousStagedTree, this.workingTree, nextBase, descriptor.docsRoot); + restoreLocalDirectories(this.workingTree, localDirectories, descriptor.docsRoot); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; this.publishingToken = ''; - this.remoteStale = false; - this.revision += 1; - await this._persistAndEmit(); + this.workspaceVersion += 1; + await this._saveAndNotify(); }); } + /** + * Cancels the matching publish transaction and unlocks staging. + * A stale or unrelated token has no effect. + */ public abortPublish(token: string) { if (token === this.publishingToken) { this.publishingToken = ''; - this._emit(); + this._notifySubscribers(); } } - protected async _transfer(source: string, destination: string, isCopy: boolean) { - assertInScope(this.descriptor, source, this.conventions); - assertInScope(this.descriptor, destination, this.conventions); - if (!this.working.entries[source]) return Promise.reject(new Error(`${source} does not exist.`)); - const { existing, metadata } = getTransferMetadata(this.working, this.index, source, destination, isCopy); - return this._mutateWithMetadata(metadata, (tree) => { + private async _transfer(source: string, destination: string, isCopy: boolean) { + assertInScope(this.descriptor, source, this.rules); + assertInScope(this.descriptor, destination, this.rules); + if (!this.workingTree.entries[source]) return Promise.reject(new Error(`${source} does not exist.`)); + const { existing, operationMetadata } = getTransferOperation( + this.workingTree, + this.stagedTree, + source, + destination, + isCopy, + ); + return this._applyOperationWithMetadata(operationMetadata, (tree) => { transferWorkspaceEntry( tree, - this._requireDescriptor(), - this.aliases, + this._getOpenDescriptor(), + this.movedPathAliases, existing, source, destination, isCopy, - metadata, - this.conventions, + operationMetadata, ); }); } - protected async _mutate(label: string, scopes: string[], apply: (tree: TreeState, metadata: ChangeMetadata) => void) { - return this._mutateWithMetadata(makeMetadata(label, scopes), (tree, metadata) => { - apply(tree, metadata); + private async _applyOperation( + label: string, + scopes: string[], + apply: (tree: TreeState, operationMetadata: OperationMetadata) => void, + ) { + return this._applyOperationWithMetadata(createOperationMetadata(label, scopes), (tree, operationMetadata) => { + apply(tree, operationMetadata); }); } - protected async _mutateWithMetadata( - metadata: ChangeMetadata, - apply: (tree: TreeState, metadata: ChangeMetadata) => void, + private async _applyOperationWithMetadata( + operationMetadata: OperationMetadata, + apply: (tree: TreeState, operationMetadata: OperationMetadata) => void, ) { - return this._enqueue(async () => { - const before = cloneTree(this.working); - apply(this.working, metadata); - this.revision += 1; - await this._persistAndEmit(); - return this._result(getPathMappings(before, this.working)); + return this._enqueueOperation(async () => { + const before = cloneTree(this.workingTree); + apply(this.workingTree, operationMetadata); + this.workspaceVersion += 1; + const result = this._createMutationResult(getPathMappings(before, this.workingTree)); + await this._saveAndNotify(); + return result; }); } - protected async _enqueue(operation: () => Promise): Promise { - const result = this.mutationQueue.then(operation, operation); - this.mutationQueue = result.then( + private async _enqueueOperation(operation: () => Promise): Promise { + const result = this.operationQueue.then(operation, operation); + this.operationQueue = result.then( () => undefined, () => undefined, ); return result; } - protected _result(pathMappings: PathMapping[] = []): MutationResult { - pathMappings.forEach(({ oldPath, newPath }) => this.aliases.set(oldPath, newPath)); - const changes = this.getWorkingChanges(); - return { - revision: this.revision, - changes, - pathMappings, - affectedDirectories: [...new Set(changes.map((change) => parentPath(change.path)).filter(Boolean))], - }; + private _createMutationResult(pathMappings: PathMapping[] = []): MutationResult { + pathMappings.forEach(({ oldPath, newPath }) => this.movedPathAliases.set(oldPath, newPath)); + return { pathMappings }; } - protected _requireDescriptor() { + private _getOpenDescriptor() { if (!this.descriptor) throw new Error('No GitHub workspace is open.'); return this.descriptor; } - protected _assertStagingUnlocked() { + private _assertCanChangeStaging() { if (this.publishingToken) throw new Error('Staging is locked while publishing.'); } - protected async _persistAndEmit() { - if (this.descriptor && this.key) { + private async _saveAndNotify() { + if (this.descriptor && this.workspaceKey) { await this.persistence.write( - this.key, + this.workspaceKey, createPersistedWorkspace({ descriptor: this.descriptor, baseCommitSha: this.baseCommitSha, baseTreeSha: this.baseTreeSha, - base: this.base, - index: this.index, - working: this.working, - revision: this.revision, - remoteStale: this.remoteStale, - conventions: this.conventions, + baseTree: this.baseTree, + stagedTree: this.stagedTree, + workingTree: this.workingTree, + workspaceVersion: this.workspaceVersion, }), ); } - this._emit(); + this._notifySubscribers(); } - protected _emit() { - this.snapshotCache = undefined; + private _notifySubscribers() { + this.cachedSnapshot = undefined; this.listeners.forEach((listener) => { listener(); }); diff --git a/packages/github-workspace/src/persistence.ts b/packages/github-workspace/src/persistence.ts index abadc94..8334bbb 100644 --- a/packages/github-workspace/src/persistence.ts +++ b/packages/github-workspace/src/persistence.ts @@ -1,11 +1,10 @@ import { DATABASE_VERSION, WORKSPACE_SCHEMA_VERSION } from './constants'; -import { cloneTree, getStagedChanges, getWorkingChanges } from './git-status'; +import { cloneTree, getLocalDirectories, getStagedChanges, getUnstagedChanges } from './git-status'; import type { IndexedDbWorkspacePersistenceOptions, PersistedWorkspace, TreeState, - WorkspaceConventions, WorkspaceDescriptor, WorkspacePersistence, } from './types'; @@ -14,12 +13,10 @@ interface PersistedWorkspaceInput { descriptor: WorkspaceDescriptor; baseCommitSha: string; baseTreeSha: string; - base: TreeState; - index: TreeState; - working: TreeState; - revision: number; - remoteStale: boolean; - conventions: WorkspaceConventions; + baseTree: TreeState; + stagedTree: TreeState; + workingTree: TreeState; + workspaceVersion: number; } export const createPersistedWorkspace = (input: PersistedWorkspaceInput): PersistedWorkspace => ({ @@ -27,11 +24,11 @@ export const createPersistedWorkspace = (input: PersistedWorkspaceInput): Persis descriptor: { ...input.descriptor }, baseCommitSha: input.baseCommitSha, baseTreeSha: input.baseTreeSha, - base: cloneTree(input.base), - workingChanges: getWorkingChanges(input.index, input.working, input.conventions), - stagedChanges: getStagedChanges(input.base, input.index, input.conventions), - revision: input.revision, - remoteStale: input.remoteStale, + baseTree: cloneTree(input.baseTree), + unstagedChanges: getUnstagedChanges(input.stagedTree, input.workingTree), + stagedChanges: getStagedChanges(input.baseTree, input.stagedTree), + localDirectories: getLocalDirectories(input.workingTree), + workspaceVersion: input.workspaceVersion, }); const openDatabase = async ({ databaseName, storeName }: IndexedDbWorkspacePersistenceOptions): Promise => diff --git a/packages/github-workspace/src/types.ts b/packages/github-workspace/src/types.ts index 02bd92f..10d7479 100644 --- a/packages/github-workspace/src/types.ts +++ b/packages/github-workspace/src/types.ts @@ -1,4 +1,6 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import type { WORKSPACE_SCHEMA_VERSION } from './constants'; + export type WorkspaceStatus = 'ADDED' | 'DELETED' | 'MODIFIED' | 'UNTRACKED'; export enum GitMode { Directory = '040000', @@ -17,9 +19,8 @@ export interface WorkspaceDescriptor { docsRoot: string; } -export interface WorkspaceConventions { - emptyDirectoryMarker: string; - managedPaths?: readonly string[]; +export interface WorkspaceRules { + protectedPaths?: readonly string[]; } export interface IndexedDbWorkspacePersistenceOptions { @@ -41,9 +42,12 @@ export interface RemoteWorkspaceSnapshot { entries: RemoteTreeEntry[]; } -export interface ChangeMetadata { +export interface OperationMetadata { + /** Connects every path changed by one user action. */ groupId: string; + /** Describes the action in the changes panel. */ label: string; + /** Lists the paths covered by the action. */ scopePaths: string[]; } @@ -56,7 +60,8 @@ export interface WorkspaceEntry { sha?: string; size?: number; content?: string; - metadata?: ChangeMetadata; + /** Records the user action that created this entry state. */ + operationMetadata?: OperationMetadata; } export interface WorkspaceChange { @@ -78,22 +83,27 @@ export interface PathMapping { } export interface MutationResult { - revision: number; - changes: WorkspaceChange[]; pathMappings: PathMapping[]; - affectedDirectories: string[]; } export interface WorkspaceSnapshot { + /** Identifies the open repository workspace. */ descriptor: WorkspaceDescriptor | null; + /** Identifies the remote commit used as the local base. */ baseCommitSha: string; + /** Identifies the Git tree used to create the next commit. */ baseTreeSha: string; - revision: number; - hydrated: boolean; - remoteStale: boolean; + /** Increases whenever workspace data changes. */ + workspaceVersion: number; + /** True after browser storage has been read. */ + persistenceLoaded: boolean; + /** True while a publish request is active. */ publishing: boolean; + /** Contains the files and folders shown in the editor. */ entries: Record; - workingChanges: WorkspaceChange[]; + /** Contains file changes that are not staged. */ + unstagedChanges: WorkspaceChange[]; + /** Contains file changes included in the next publish. */ stagedChanges: WorkspaceChange[]; } @@ -112,26 +122,22 @@ export interface PublishPlan { entries: PublishTreeEntry[]; } -export interface RebaseResult { - conflicts: string[]; - rebased: boolean; -} - export interface TreeState { entries: Record; - deletedMetadata: Record; + /** Keeps operation details for entries that no longer exist. */ + deletedOperationMetadata: Record; } export interface PersistedWorkspace { - schemaVersion: number; + schemaVersion: typeof WORKSPACE_SCHEMA_VERSION; descriptor: WorkspaceDescriptor; baseCommitSha: string; baseTreeSha: string; - base: TreeState; - workingChanges: WorkspaceChange[]; + baseTree: TreeState; + unstagedChanges: WorkspaceChange[]; stagedChanges: WorkspaceChange[]; - revision: number; - remoteStale: boolean; + localDirectories: WorkspaceEntry[]; + workspaceVersion: number; } export interface WorkspacePersistence { diff --git a/packages/github-workspace/src/utils.ts b/packages/github-workspace/src/utils.ts index 6e78798..9ce5a21 100644 --- a/packages/github-workspace/src/utils.ts +++ b/packages/github-workspace/src/utils.ts @@ -1,4 +1,4 @@ -import type { ChangeMetadata, WorkspaceConventions, WorkspaceDescriptor } from './types'; +import type { OperationMetadata, WorkspaceDescriptor, WorkspaceRules } from './types'; export const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); @@ -6,12 +6,13 @@ export const parentPath = (path: string) => path.split('/').slice(0, -1).join('/ export const isSameOrDescendant = (path: string, root: string) => path === root || path.startsWith(`${root}/`); -export const pathsOverlap = (left: string, right: string) => - isSameOrDescendant(left, right) || isSameOrDescendant(right, left); - export const createId = (): string => window.crypto.randomUUID(); -export const makeMetadata = (label: string, scopePaths: string[], groupId = createId()): ChangeMetadata => ({ +export const createOperationMetadata = ( + label: string, + scopePaths: string[], + groupId = createId(), +): OperationMetadata => ({ groupId, label, scopePaths, @@ -20,5 +21,5 @@ export const makeMetadata = (label: string, scopePaths: string[], groupId = crea export const getWorkspaceKey = (descriptor: WorkspaceDescriptor) => [descriptor.owner, descriptor.repo, descriptor.branch, cleanPath(descriptor.docsRoot)].join('/'); -export const isManagedPath = (path: string, conventions: WorkspaceConventions) => - conventions.managedPaths?.some((managedPath) => cleanPath(managedPath) === path) ?? false; +export const isProtectedPath = (path: string, rules: WorkspaceRules) => + rules.protectedPaths?.some((protectedPath) => cleanPath(protectedPath) === path) ?? false; diff --git a/packages/github-workspace/src/workspace-operations.ts b/packages/github-workspace/src/workspace-operations.ts index 95d9376..9678fd6 100644 --- a/packages/github-workspace/src/workspace-operations.ts +++ b/packages/github-workspace/src/workspace-operations.ts @@ -2,23 +2,19 @@ import { cloneEntry, - cloneMetadata, + cloneOperationMetadata, copyPathState, createDirectoryEntry, getDirectoryPaths, getStagedChanges, - getWorkingChanges, + getUnstagedChanges, } from './git-status'; import { GitMode } from './types'; -import { cleanPath, createId, isManagedPath, isSameOrDescendant, makeMetadata, parentPath } from './utils'; +import { cleanPath, createId, createOperationMetadata, isProtectedPath, isSameOrDescendant, parentPath } from './utils'; -import type { ChangeMetadata, TreeState, WorkspaceConventions, WorkspaceDescriptor, WorkspaceEntry } from './types'; +import type { OperationMetadata, TreeState, WorkspaceDescriptor, WorkspaceEntry, WorkspaceRules } from './types'; -export const assertInScope = ( - descriptor: WorkspaceDescriptor | null, - path: string, - conventions: WorkspaceConventions, -) => { +export const assertInScope = (descriptor: WorkspaceDescriptor | null, path: string, rules: WorkspaceRules) => { if (!descriptor) throw new Error('No GitHub workspace is open.'); const clean = cleanPath(path); const segments = clean.split('/'); @@ -27,8 +23,8 @@ export const assertInScope = ( } const root = cleanPath(descriptor.docsRoot); if (root && !clean.startsWith(`${root}/`)) throw new Error(`${path} is outside the configured docs root.`); - if (isManagedPath(clean, conventions)) { - throw new Error(`${path} is managed by the workspace and cannot be changed here.`); + if (isProtectedPath(clean, rules)) { + throw new Error(`${path} is protected by the workspace and cannot be changed here.`); } }; @@ -38,51 +34,29 @@ const assertAvailable = (tree: TreeState, path: string) => { } }; -const ensureParents = (tree: TreeState, descriptor: WorkspaceDescriptor, path: string, metadata?: ChangeMetadata) => { +const ensureParents = ( + tree: TreeState, + descriptor: WorkspaceDescriptor, + path: string, + operationMetadata?: OperationMetadata, +) => { const root = cleanPath(descriptor.docsRoot); getDirectoryPaths(path, root).forEach((directory) => { - tree.entries[directory] ??= { ...createDirectoryEntry(directory), metadata }; + tree.entries[directory] ??= { ...createDirectoryEntry(directory), operationMetadata }; }); }; -const removeParentMarker = (tree: TreeState, path: string, metadata: ChangeMetadata, emptyDirectoryMarker: string) => { - const marker = `${parentPath(path)}/${emptyDirectoryMarker}`.replace(/^\//, ''); - if (!tree.entries[marker]) return; - delete tree.entries[marker]; - tree.deletedMetadata[marker] = metadata; +const keepEmptyDirectoryLocal = (tree: TreeState, path: string, operationMetadata: OperationMetadata) => { + const directory = tree.entries[path]; + if (!directory || directory.kind !== 'directory') return; + const hasDescendant = Object.keys(tree.entries).some((entryPath) => entryPath.startsWith(`${path}/`)); + if (!hasDescendant) directory.operationMetadata = operationMetadata; }; -const ensureEmptyDirectoryMarker = ( - tree: TreeState, - directory: string, - metadata: ChangeMetadata, - emptyDirectoryMarker: string, -) => { - if (!directory || !tree.entries[directory] || tree.entries[directory].kind !== 'directory') return; - const hasChild = Object.values(tree.entries).some( - (entry) => parentPath(entry.path) === directory && entry.path !== `${directory}/${emptyDirectoryMarker}`, - ); - const marker = `${directory}/${emptyDirectoryMarker}`; - if (!hasChild && !tree.entries[marker]) { - tree.entries[marker] = { - id: createId(), - path: marker, - kind: 'file', - mode: GitMode.File, - type: 'blob', - content: '', - metadata, - }; - } -}; - -export const listDirectoryEntries = (tree: TreeState, path: string, conventions: WorkspaceConventions) => { +export const listDirectoryEntries = (tree: TreeState, path: string) => { const directory = cleanPath(path); return Object.values(tree.entries) - .filter( - (entry) => - parentPath(entry.path) === directory && entry.path.split('/').at(-1) !== conventions.emptyDirectoryMarker, - ) + .filter((entry) => parentPath(entry.path) === directory) .map(cloneEntry) .sort((left, right) => { if (left.kind !== right.kind) return left.kind === 'directory' ? -1 : 1; @@ -112,12 +86,10 @@ export const createFileEntry = ( descriptor: WorkspaceDescriptor, path: string, content: string, - metadata: ChangeMetadata, - conventions: WorkspaceConventions, + operationMetadata: OperationMetadata, ) => { assertAvailable(tree, path); - ensureParents(tree, descriptor, path, metadata); - removeParentMarker(tree, path, metadata, conventions.emptyDirectoryMarker); + ensureParents(tree, descriptor, path, operationMetadata); tree.entries[path] = { id: createId(), path, @@ -125,7 +97,7 @@ export const createFileEntry = ( mode: GitMode.File, type: 'blob', content, - metadata, + operationMetadata, }; }; @@ -133,66 +105,61 @@ export const createDirectory = ( tree: TreeState, descriptor: WorkspaceDescriptor, path: string, - metadata: ChangeMetadata, - conventions: WorkspaceConventions, + operationMetadata: OperationMetadata, ) => { assertAvailable(tree, path); - ensureParents(tree, descriptor, `${path}/child`, metadata); - tree.entries[path] = { ...createDirectoryEntry(path), id: createId(), metadata }; - tree.entries[`${path}/${conventions.emptyDirectoryMarker}`] = { - id: createId(), - path: `${path}/${conventions.emptyDirectoryMarker}`, - kind: 'file', - mode: GitMode.File, - type: 'blob', - content: '', - metadata, - }; - removeParentMarker(tree, path, metadata, conventions.emptyDirectoryMarker); + ensureParents(tree, descriptor, `${path}/child`, operationMetadata); + tree.entries[path] = { ...createDirectoryEntry(path), id: createId(), operationMetadata }; }; -export const writeFileEntry = (tree: TreeState, path: string, content: string, metadata: ChangeMetadata) => { +export const writeFileEntry = ( + tree: TreeState, + path: string, + content: string, + operationMetadata: OperationMetadata, +) => { const entry = tree.entries[path]; if (!entry || entry.kind !== 'file') throw new Error(`The file ${path} does not exist.`); entry.content = content; - entry.metadata = metadata; + entry.operationMetadata = operationMetadata; }; export const deleteWorkspaceEntry = ( tree: TreeState, - base: TreeState, + baseTree: TreeState, path: string, - metadata: ChangeMetadata, - conventions: WorkspaceConventions, + operationMetadata: OperationMetadata, ) => { const targets = Object.keys(tree.entries).filter((entryPath) => isSameOrDescendant(entryPath, path)); if (!targets.length) throw new Error(`${path} does not exist.`); targets.forEach((target) => { - const origin = Object.values(base.entries).find((entry) => entry.id === tree.entries[target]?.id); + const origin = Object.values(baseTree.entries).find((entry) => entry.id === tree.entries[target]?.id); delete tree.entries[target]; - tree.deletedMetadata[target] = metadata; - if (origin) tree.deletedMetadata[origin.path] = metadata; + tree.deletedOperationMetadata[target] = operationMetadata; + if (origin) tree.deletedOperationMetadata[origin.path] = operationMetadata; }); - ensureEmptyDirectoryMarker(tree, parentPath(path), metadata, conventions.emptyDirectoryMarker); + keepEmptyDirectoryLocal(tree, parentPath(path), operationMetadata); }; -export const getTransferMetadata = ( - working: TreeState, - index: TreeState, +export const getTransferOperation = ( + workingTree: TreeState, + stagedTree: TreeState, source: string, destination: string, isCopy: boolean, ) => { - const existing = working.entries[source]; + const existing = workingTree.entries[source]; if (!existing) throw new Error(`${source} does not exist.`); - const inheritedMetadata = !isCopy && existing.metadata && !index.entries[source] ? existing.metadata : undefined; - const metadata = - inheritedMetadata ?? makeMetadata(`${isCopy ? 'Copy' : 'Move'} ${source} to ${destination}`, [source, destination]); - metadata.label = inheritedMetadata + const inheritedOperationMetadata = + !isCopy && existing.operationMetadata && !stagedTree.entries[source] ? existing.operationMetadata : undefined; + const operationMetadata = + inheritedOperationMetadata ?? + createOperationMetadata(`${isCopy ? 'Copy' : 'Move'} ${source} to ${destination}`, [source, destination]); + operationMetadata.label = inheritedOperationMetadata ? `${existing.kind === 'file' ? 'Create file' : 'Create folder'} ${destination}` - : metadata.label; - metadata.scopePaths = [source, destination]; - return { existing, metadata }; + : operationMetadata.label; + operationMetadata.scopePaths = [source, destination]; + return { existing, operationMetadata }; }; export const transferWorkspaceEntry = ( @@ -203,77 +170,64 @@ export const transferWorkspaceEntry = ( source: string, destination: string, isCopy: boolean, - metadata: ChangeMetadata, - conventions: WorkspaceConventions, + operationMetadata: OperationMetadata, ) => { if (existing.kind === 'directory' && isSameOrDescendant(destination, source)) { throw new Error('A directory cannot be moved or copied into itself.'); } assertAvailable(tree, destination); const targets = Object.values(tree.entries).filter((entry) => isSameOrDescendant(entry.path, source)); - ensureParents(tree, descriptor, `${destination}/child`, metadata); + ensureParents(tree, descriptor, `${destination}/child`, operationMetadata); targets.forEach((entry) => { const destinationPath = entry.path === source ? destination : `${destination}${entry.path.slice(source.length)}`; tree.entries[destinationPath] = { ...cloneEntry(entry), id: isCopy ? createId() : entry.id, path: destinationPath, - metadata, + operationMetadata, }; - delete tree.deletedMetadata[destinationPath]; + delete tree.deletedOperationMetadata[destinationPath]; if (!isCopy) { delete tree.entries[entry.path]; - tree.deletedMetadata[entry.path] = metadata; + tree.deletedOperationMetadata[entry.path] = operationMetadata; aliases.set(entry.path, destinationPath); } }); - removeParentMarker(tree, destination, metadata, conventions.emptyDirectoryMarker); - if (!isCopy) ensureEmptyDirectoryMarker(tree, parentPath(source), metadata, conventions.emptyDirectoryMarker); + if (!isCopy) keepEmptyDirectoryLocal(tree, parentPath(source), operationMetadata); }; export const stageChanges = ( - base: TreeState, - index: TreeState, - working: TreeState, + baseTree: TreeState, + stagedTree: TreeState, + workingTree: TreeState, groupIds: string[], - conventions: WorkspaceConventions, ) => { const selected = new Set(groupIds); - getWorkingChanges(index, working, conventions) + getUnstagedChanges(stagedTree, workingTree) .filter((change) => selected.has(change.groupId)) .forEach((change) => { - copyPathState(working, index, change.path); + copyPathState(workingTree, stagedTree, change.path); if (!change.newEntry && change.oldEntry) { - const origin = Object.values(base.entries).find((entry) => entry.id === change.oldEntry?.id); - if (origin) index.deletedMetadata[origin.path] = cloneMetadata(change); + const origin = Object.values(baseTree.entries).find((entry) => entry.id === change.oldEntry?.id); + if (origin) stagedTree.deletedOperationMetadata[origin.path] = cloneOperationMetadata(change); } }); }; -export const unstageChanges = ( - base: TreeState, - index: TreeState, - groupIds: string[], - conventions: WorkspaceConventions, -) => { +export const unstageChanges = (baseTree: TreeState, stagedTree: TreeState, groupIds: string[]) => { const selected = new Set(groupIds); - getStagedChanges(base, index, conventions) + getStagedChanges(baseTree, stagedTree) .filter((change) => selected.has(change.groupId)) .forEach((change) => { - copyPathState(base, index, change.path); + copyPathState(baseTree, stagedTree, change.path); }); }; -export const restoreWorkingChanges = ( - index: TreeState, - working: TreeState, - groupIds: string[], - conventions: WorkspaceConventions, -) => { +export const restoreWorkingChanges = (stagedTree: TreeState, workingTree: TreeState, groupIds: string[]) => { const selected = new Set(groupIds); - getWorkingChanges(index, working, conventions) + getUnstagedChanges(stagedTree, workingTree) .filter((change) => selected.has(change.groupId)) .forEach((change) => { - copyPathState(index, working, change.path); + copyPathState(stagedTree, workingTree, change.path); }); }; From 17242a7f85dd0d12119bfe12189c695a534cc597 Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 28 Aug 2026 18:46:11 +0800 Subject: [PATCH 10/17] feat: add github avatar in the settings; cache user info and indicate workspace mode in footer --- client/src/components/Editor/DraftEditor.tsx | 4 +- client/src/components/Footer/Footer.scss | 23 +++++++ client/src/components/Footer/Footer.tsx | 12 +++- .../components/Settings/GitHubSettings.tsx | 11 +++- client/src/components/Settings/Settings.scss | 11 ++++ client/src/components/Settings/Settings.tsx | 5 +- client/src/utils/hooks/githubAuthHooks.ts | 63 ++++++++++++++----- 7 files changed, 105 insertions(+), 24 deletions(-) diff --git a/client/src/components/Editor/DraftEditor.tsx b/client/src/components/Editor/DraftEditor.tsx index 1736071..604935e 100644 --- a/client/src/components/Editor/DraftEditor.tsx +++ b/client/src/components/Editor/DraftEditor.tsx @@ -91,7 +91,7 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, }; }, []); - // when switching the doc (or same doc refetched) + // when switching the doc (or same doc re-fetched) useEffect(() => { if (doc?.id === contentIdent) return; @@ -121,6 +121,8 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, // only for draft doc, since internal doc should not be edited and saved const onUpdated = (ctx: Ctx, markdown: string) => { + if (type === 'internal') return; + const isDirty = normalizeEOL(markdown) !== normalizeEOL(doc?.content); const headings = outline()(ctx); diff --git a/client/src/components/Footer/Footer.scss b/client/src/components/Footer/Footer.scss index e97605e..4ea88ea 100644 --- a/client/src/components/Footer/Footer.scss +++ b/client/src/components/Footer/Footer.scss @@ -20,6 +20,29 @@ } } } + .workspace-mode-tag { + display: inline-flex; + gap: 4px; + align-items: center; + height: 20px; + padding: 0 7px; + color: $headerTextColor; + font-size: 11px; + font-weight: 500; + line-height: 1; + background: transparent; + border: 1px solid $panelBorderColor; + .p-tag-icon { + display: inline-flex; + align-items: center; + margin-right: 0; + font-size: 12px; + line-height: 1; + } + .p-tag-value { + line-height: 1; + } + } } .right-group { margin-right: 0.5rem; diff --git a/client/src/components/Footer/Footer.tsx b/client/src/components/Footer/Footer.tsx index e938635..5008372 100644 --- a/client/src/components/Footer/Footer.tsx +++ b/client/src/components/Footer/Footer.tsx @@ -3,11 +3,13 @@ import BallotIcon from '@mui/icons-material/BallotOutlined'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExitOutlined'; import FullscreenIcon from '@mui/icons-material/FullscreenOutlined'; import MirrorIcon from '@mui/icons-material/ImportContactsOutlined'; +import { Tag } from 'primereact/tag'; import { FC } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { Icon } from '@/components/Icon/Icon'; +import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { selectMirrorCollapse, selectNarrowMode, @@ -26,6 +28,7 @@ export const Footer: FC = () => { const mirrorCollapse = useSelector(selectMirrorCollapse); const narrowMode = useSelector(selectNarrowMode); const serverStatus = useSelector(selectServerStatus); + const workspaceMode = useSelector(selectWorkspaceMode); const navigate = useNavigate(); const switchNarrowMode = useSwitchNarrowMode(); @@ -48,11 +51,18 @@ export const Footer: FC = () => { onClick={() => { if (serverStatus === ServerStatus.VERSION_MISMATCHE) { void navigate('/internal/version-mismatch'); - } else if (serverStatus === ServerStatus.RUNNING) { + } else { void navigate('/internal/guide'); } }} /> +
    { const navigate = useNavigate(); const saveDoc = useSaveDoc(); const workspace = useSelector(selectGithubWorkspace); - const { githubLogin, isGitHubLoginLoading, startGitHubInstall, startGitHubLogin } = useGitHubLogin(); + const { githubLogin, githubAvatarUrl, isGitHubLoginLoading, startGitHubInstall, startGitHubLogin } = useGitHubLogin(); const { data: repositories = [], error: repositoriesError, @@ -194,7 +194,12 @@ export const GitHubSettings: FC = () => { <>
    - Signed in as {githubLogin} +
    + {githubAvatarUrl && ( + {`${githubLogin}'s + )} + Signed in as {githubLogin} +
    {repositoriesError && 'message' in repositoriesError && ( {repositoriesError.message} )} diff --git a/client/src/components/Settings/Settings.scss b/client/src/components/Settings/Settings.scss index 9c0d261..2ba8ea2 100644 --- a/client/src/components/Settings/Settings.scss +++ b/client/src/components/Settings/Settings.scss @@ -26,6 +26,17 @@ .github-access-error { color: var(--red-500, #dc2626); } + .github-account-details { + display: flex; + gap: 10px; + align-items: center; + } + .github-settings-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + } .setting-label { display: block; font-size: 20px; diff --git a/client/src/components/Settings/Settings.tsx b/client/src/components/Settings/Settings.tsx index 1f4f763..b276b6f 100644 --- a/client/src/components/Settings/Settings.tsx +++ b/client/src/components/Settings/Settings.tsx @@ -11,7 +11,7 @@ import { GitHubSettings } from './GitHubSettings'; import { FolderSelectorModal } from '@/components/FolderSelector/FolderSelector'; import { useGetGitStatusQuery } from '@/redux-api/git'; import { Settings } from '@/redux-api/settings'; -import { selectCurDocDirty, updateTabs } from '@/redux-feature/curDocSlice'; +import { selectCurDocDirty, selectCurDocType, updateTabs } from '@/redux-feature/curDocSlice'; import { selectGithubWorkspace, setWorkspaceMode, type WorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; import { useSaveDoc } from '@/utils/hooks/reduxHooks'; import { confirm } from '@/utils/utils'; @@ -33,6 +33,7 @@ export const SettingsBox: FC = ({ settings, onUpdateSettings } const navigate = useNavigate(); const saveDoc = useSaveDoc(); const isDirty = useSelector(selectCurDocDirty); + const currentDocType = useSelector(selectCurDocType); const workspaceMode = useSelector(selectGithubWorkspace).mode; const [workspace, setWorkspace] = useState(''); const [ignoreDirs, setIgnoreDirs] = useState([]); @@ -94,7 +95,7 @@ export const SettingsBox: FC = ({ settings, onUpdateSettings } const switchWorkspaceMode = async (mode: WorkspaceMode) => { if (mode === workspaceMode) return; - if (isDirty) { + if (isDirty && currentDocType === 'workspace') { const shouldSave = await confirm({ message: 'Save the current document before switching workspace modes?' }); if (!shouldSave) return; await saveDoc(); diff --git a/client/src/utils/hooks/githubAuthHooks.ts b/client/src/utils/hooks/githubAuthHooks.ts index 24c77f7..33dcfad 100644 --- a/client/src/utils/hooks/githubAuthHooks.ts +++ b/client/src/utils/hooks/githubAuthHooks.ts @@ -26,6 +26,8 @@ interface GitHubUser { } let githubAccessToken = window.localStorage.getItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY); +let githubUserCache: { accessToken: string; user: GitHubUser } | null = null; +let githubUserRequest: { accessToken: string; promise: Promise } | null = null; const githubAccessTokenListeners = new Set<() => void>(); const notifyGitHubAccessTokenListeners = () => { @@ -37,6 +39,8 @@ const notifyGitHubAccessTokenListeners = () => { const setGitHubAccessToken = (token: string | null) => { if (token === githubAccessToken) return; githubAccessToken = token; + if (githubUserCache?.accessToken !== token) githubUserCache = null; + if (githubUserRequest?.accessToken !== token) githubUserRequest = null; if (token) { window.localStorage.setItem(GITHUB_ACCESS_TOKEN_STORAGE_KEY, token); } else { @@ -55,6 +59,8 @@ const subscribeToGitHubAccessToken = (listener: () => void) => { window.addEventListener('storage', (event) => { if (event.key !== GITHUB_ACCESS_TOKEN_STORAGE_KEY || event.newValue === githubAccessToken) return; githubAccessToken = event.newValue; + if (githubUserCache?.accessToken !== event.newValue) githubUserCache = null; + if (githubUserRequest?.accessToken !== event.newValue) githubUserRequest = null; notifyGitHubAccessTokenListeners(); }); @@ -96,34 +102,49 @@ export const startGitHubInstall = () => { startGitHubAuth('install'); }; +const getCachedGitHubUser = (token: string | null) => + token && githubUserCache?.accessToken === token ? githubUserCache.user : null; + const getGitHubUser = async (token: string): Promise => { - const response = await fetch('https://api.github.com/user', { + const cachedUser = getCachedGitHubUser(token); + if (cachedUser) return Promise.resolve(cachedUser); + if (githubUserRequest?.accessToken === token) return githubUserRequest.promise; + + const promise = fetch('https://api.github.com/user', { headers: { accept: 'application/vnd.github+json', authorization: `Bearer ${token}`, }, - }); - - if (!response.ok) { - return null; - } - - const user = (await response.json()) as GitHubUserResponse; - return user.login - ? { - login: user.login, - avatarUrl: user.avatar_url ?? null, - } - : null; + }) + .then(async (response) => { + if (!response.ok) return null; + + const responseUser = (await response.json()) as GitHubUserResponse; + if (!responseUser.login) return null; + + const user = { + login: responseUser.login, + avatarUrl: responseUser.avatar_url ?? null, + }; + githubUserCache = { accessToken: token, user }; + return user; + }) + .finally(() => { + if (githubUserRequest?.promise === promise) githubUserRequest = null; + }); + + githubUserRequest = { accessToken: token, promise }; + return promise; }; /** Manages the GitHub OAuth callback and persisted access token. */ export const useGitHubLogin = () => { const oauthInProgressRef = useRef(false); const accessToken = useGitHubAccessToken(); - const [githubLogin, setGithubLogin] = useState(null); - const [githubAvatarUrl, setGithubAvatarUrl] = useState(null); - const [isGitHubLoginLoading, setIsGitHubLoginLoading] = useState(true); + const cachedUser = getCachedGitHubUser(accessToken); + const [githubLogin, setGithubLogin] = useState(cachedUser?.login ?? null); + const [githubAvatarUrl, setGithubAvatarUrl] = useState(cachedUser?.avatarUrl ?? null); + const [isGitHubLoginLoading, setIsGitHubLoginLoading] = useState(!cachedUser); useEffect(() => { const url = new URL(window.location.href); @@ -194,6 +215,14 @@ export const useGitHubLogin = () => { return; } + const currentCachedUser = getCachedGitHubUser(accessToken); + if (currentCachedUser) { + setGithubLogin(currentCachedUser.login); + setGithubAvatarUrl(currentCachedUser.avatarUrl); + setIsGitHubLoginLoading(false); + return; + } + let cancelled = false; setIsGitHubLoginLoading(true); void getGitHubUser(accessToken) From c37c530a9666d3f63b598cc00ef1e82cad4de0fb Mon Sep 17 00:00:00 2001 From: s-elo Date: Fri, 28 Aug 2026 19:05:24 +0800 Subject: [PATCH 11/17] feat: add link to jump to the repo at repo list selector in github settings --- .../components/Settings/GitHubSettings.tsx | 23 +++++++++++++++ client/src/components/Settings/Settings.scss | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/client/src/components/Settings/GitHubSettings.tsx b/client/src/components/Settings/GitHubSettings.tsx index 71734ec..df127a7 100644 --- a/client/src/components/Settings/GitHubSettings.tsx +++ b/client/src/components/Settings/GitHubSettings.tsx @@ -7,6 +7,7 @@ import { FC, useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import { Icon } from '@/components/Icon/Icon'; import { WORKSPACE_SETTINGS_PATH } from '@/constants'; import { useCreateGitHubRepositoryMutation, @@ -86,6 +87,27 @@ export const GitHubSettings: FC = () => { if (selectedRepository && !repositoryOptions.some((option) => option.value === selectedRepository)) { repositoryOptions.unshift({ label: selectedRepository, value: selectedRepository }); } + const repositoryItemTemplate = (option: { label: string; value: string }) => { + const repositoryUrl = `https://github.com/${option.value}`; + + return ( +
    + {option.label} + { + event.preventDefault(); + event.stopPropagation(); + window.open(repositoryUrl, '_blank', 'noopener,noreferrer'); + }} + /> +
    + ); + }; const branchOptions = branches.map((item) => ({ label: item, value: item })); if (branch && !branchOptions.some((option) => option.value === branch)) { branchOptions.unshift({ label: branch, value: branch }); @@ -213,6 +235,7 @@ export const GitHubSettings: FC = () => { span:first-child { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.25rem; + } + .github-repository-open-icon { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + opacity: 0.7; + transition: + background-color 0.15s ease, + opacity 0.15s ease; + &:hover, + &:focus-visible { + opacity: 1; + } + } +} From 69b13a94e4888e977150778b7d1b92b9f19e4737 Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 2 Sep 2026 11:46:37 +0800 Subject: [PATCH 12/17] feat: fallback to lazy load git tree if being truncated --- client/src/components/Editor/Editor.tsx | 15 +- client/src/components/Menu/Menu.tsx | 20 +-- client/src/components/Menu/MenuItem.tsx | 8 +- client/src/components/Menu/operations.tsx | 29 +++- client/src/components/Menu/type.ts | 4 +- client/src/redux-api/github.ts | 162 ++++++++++++++++--- client/src/utils/hooks/reduxHooks.ts | 2 + client/src/utils/hooks/workspaceHooks.ts | 157 ++++++++++++++++-- packages/github-workspace/README.md | 11 +- packages/github-workspace/src/git-status.ts | 4 +- packages/github-workspace/src/index.ts | 128 ++++++++++++++- packages/github-workspace/src/persistence.ts | 5 + packages/github-workspace/src/types.ts | 24 ++- 13 files changed, 506 insertions(+), 63 deletions(-) diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 42e6b10..6a63614 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -15,6 +15,7 @@ import { updateCurDoc, selectCurDoc, selectCurTabs, clearCurDoc } from '@/redux- import { clearDraft, selectDraft, setDraft } from '@/redux-feature/draftsSlice'; import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; import { selectNarrowMode, selectReadonly, selectTheme } from '@/redux-feature/globalOptsSlice'; +import { useDeleteTab } from '@/utils/hooks/reduxHooks'; import { useWorkspaceDocQuery } from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; import { getDraftKey, normalizePath, normalizeEOL } from '@/utils/utils'; @@ -45,7 +46,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } // useGetDocQuery will be cached (within a limited time) according to different contentPath // with auto refetch when the doc is updated const githubWorkspace = useSelector(selectGithubWorkspace); - const { data: fetchedDoc = getDefaultDoc(), isSuccess, error } = useWorkspaceDocQuery(curDocPath); + const { data: fetchedDoc = getDefaultDoc(), isSuccess, error, isMissing } = useWorkspaceDocQuery(curDocPath); const { data: settings } = useGetSettingsQuery(undefined, { skip: githubWorkspace.mode === 'github' }); const { content: storedContent, contentIdent: storedContentPath } = useSelector(selectCurDoc); @@ -58,6 +59,7 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } const narrowMode = useSelector(selectNarrowMode); const dispatch = useDispatch(); + const deleteTab = useDeleteTab(); const crepeEditorRef = useRef(null); @@ -128,9 +130,16 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } useEffect(() => { if (!error) return; - void navigate('/'); + if (isMissing) { + // Removing the missing tab also navigates to the last remaining tab (or purePage + // when no tabs remain), using the same flow as a user-closing or deleted document. + void deleteTab([curDocPath], { force: true }); + } else { + // Do not delete a valid tab for transient loading, authentication, or network errors. + void navigate('/purePage'); + } Toast.error((error as unknown as Error).message ?? 'Failed to fetch doc'); - }, [error, navigate]); + }, [curDocPath, error, isMissing, navigate]); // when switching the doc (or same doc re-fetched) useEffect(() => { diff --git a/client/src/components/Menu/Menu.tsx b/client/src/components/Menu/Menu.tsx index 9546118..0b4f196 100644 --- a/client/src/components/Menu/Menu.tsx +++ b/client/src/components/Menu/Menu.tsx @@ -19,7 +19,7 @@ import { Empty } from './Empty'; import { useDropDoc, useNewDocItem, usePasteDoc, useUpdateSubDocItems } from './operations'; import { createRenderItem, renderDragBetweenLine, renderItemArrow } from './renderer'; import { Shortcut } from './Shortcut'; -import { TreeDataCtx, TreeRefCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type'; +import { TreeDataCtx, TreeItemData, MenuCtx, TreeEnvRefCtx } from './type'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; import { selectOperationMenu, updateSelectedItems } from '@/redux-feature/operationMenuSlice'; @@ -60,7 +60,7 @@ export const Menu: FC = () => { // first level sorted docs children: docRootItems.map((d) => normalizePath(d.path)), canRename: false, - data: { path: [], id: 'root', name: 'root', parentIdx: '' }, + data: { path: [], id: 'root', name: 'root', parentIdx: '', childrenLoaded: true }, }, }; @@ -75,7 +75,7 @@ export const Menu: FC = () => { isFolder: !isFile, children: [], canRename: true, - data: { path, id, name, parentIdx }, + data: { path, id, name, parentIdx, childrenLoaded: isFile }, }; return treeData; }, root); @@ -103,7 +103,9 @@ export const Menu: FC = () => { void treeDataProvider.onDidChangeTreeDataEmitter.emit(['root']); }, [treeDataProvider]); + // refresh the expanded folders(basically the tree) when workspace or providers changed useEffect(() => { + /** prevents the loop from starting further updates after the effect has become stale or the component has unmounted. */ let cancelled = false; const refreshExpandedFolders = async () => { const expandedItems = treeEnvRef.current?.viewState[MENU_TREE_ID]?.expandedItems ?? []; @@ -126,6 +128,7 @@ export const Menu: FC = () => { }; }, [dataVersion, renderData, treeDataProvider, updateSubDocItems]); + // sync the selection of menu with opening doc useEffect(() => { if (contentPath) { const fn = async () => { @@ -133,7 +136,7 @@ export const Menu: FC = () => { // TODO: request sub docs in parallel, may need to wait for the parent doc logics for (const docIdx of expandKeys) { const docItem = renderData[docIdx]; - if (!docItem || docItem.children?.length) continue; + if (!docItem || docItem.data.childrenLoaded) continue; await updateSubDocItems(docItem, renderData, treeDataProvider); } @@ -218,12 +221,13 @@ export const Menu: FC = () => { } }; - const onExpandItem = async (item: TreeItem) => { + const onExpandItem = async (item?: TreeItem) => { + if (!item) return; const currentItem = renderData[item.index]; if (!currentItem) return; // already fetched - if (currentItem.children?.length) return; + if (currentItem.data.childrenLoaded) return; await updateSubDocItems(currentItem, renderData, treeDataProvider); }; @@ -296,9 +300,7 @@ export const Menu: FC = () => { data: renderData, }} > - - {content} - + {content}
    diff --git a/client/src/components/Menu/MenuItem.tsx b/client/src/components/Menu/MenuItem.tsx index 0c3dc4f..7f3c5e5 100644 --- a/client/src/components/Menu/MenuItem.tsx +++ b/client/src/components/Menu/MenuItem.tsx @@ -77,7 +77,13 @@ export const MenuItem: FC = ({ title, arrow, context, item, depth const onClickCommand = async (command: Command) => { if (command === 'newFile' || command === 'newFolder') { - await createNewDocItem(item, command === 'newFolder'); + await createNewDocItem( + item, + command === 'newFolder', + undefined, + undefined, + context.isExpanded ? undefined : context.expandItem, + ); } else if (command === 'delete') { await deleteDoc(item); } else if (command === 'rename') { diff --git a/client/src/components/Menu/operations.tsx b/client/src/components/Menu/operations.tsx index 54404a7..5248f73 100644 --- a/client/src/components/Menu/operations.tsx +++ b/client/src/components/Menu/operations.tsx @@ -6,7 +6,7 @@ import { DraggingPosition, StaticTreeDataProvider, TreeItem, TreeItemIndex } fro import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import { TreeDataCtx, TreeItemData, TreeRefCtx } from './type'; +import { TreeDataCtx, TreeItemData } from './type'; import { selectCurDocDirty, updateCurDoc } from '@/redux-feature/curDocSlice'; import { selectOperationMenu, selectSelectedItemIds, updateCopyCut } from '@/redux-feature/operationMenuSlice'; @@ -66,7 +66,7 @@ export const useUpdateSubDocItems = () => { }); if (status !== QueryStatus.fulfilled) { Toast.error('Failed to get sub doc items'); - return; + return false; } newSubItems.forEach(({ id, name, isFile, path }) => { @@ -78,11 +78,13 @@ export const useUpdateSubDocItems = () => { isFolder: !isFile, children: [], canRename: true, - data: { path, id, name, parentIdx: currentParentItem.index }, + data: { path, id, name, parentIdx: currentParentItem.index, childrenLoaded: isFile }, }; }); currentParentItem.children = newSubItems.map((d) => normalizePath(d.path)); + currentParentItem.data.childrenLoaded = true; await provider.onDidChangeTreeDataEmitter.emit([currentParentItem.index, ...currentParentItem.children]); + return true; }, [getDocSubItems], ); @@ -90,7 +92,7 @@ export const useUpdateSubDocItems = () => { export const useNewDocItem = () => { const treeDataCtx = useContext(TreeDataCtx); - const treeRefCtx = useContext(TreeRefCtx); + const updateSubDocItems = useUpdateSubDocItems(); return async ( item: TreeItem, @@ -98,11 +100,19 @@ export const useNewDocItem = () => { // for root menu to provide treeProvider?: StaticTreeDataProvider, renderData?: Record>, + expandParent?: () => void, ) => { const treeData = renderData ?? treeDataCtx?.data; const provider = treeProvider ?? treeDataCtx?.provider; if (!treeData || !provider) return; + const currentParent = treeData[item.index]; + if (!currentParent) return; + if (!currentParent.data.childrenLoaded) { + const loaded = await updateSubDocItems(currentParent, treeData, provider); + if (!loaded) return; + } + const id = `${Math.random()}`; const newItem: TreeItem = { index: id, @@ -115,16 +125,21 @@ export const useNewDocItem = () => { path: [], id, name: 'newDoc', + childrenLoaded: true, newFile: !isFolder, newFolder: isFolder, }, }; treeData[id] = newItem; - item.children?.unshift(id); + currentParent.children?.unshift(id); - await provider.onDidChangeTreeDataEmitter.emit([item.index]); + await provider.onDidChangeTreeDataEmitter.emit([currentParent.index, id]); - treeRefCtx?.expandItem(item.index); + // Expand only after the provider knows about the new child. The render-context action + // captures the parent item directly, so it avoids TreeRef's potentially stale item lookup. + if (expandParent) { + requestAnimationFrame(expandParent); + } }; }; diff --git a/client/src/components/Menu/type.ts b/client/src/components/Menu/type.ts index 71e4ffb..09d9297 100644 --- a/client/src/components/Menu/type.ts +++ b/client/src/components/Menu/type.ts @@ -1,9 +1,10 @@ import { createContext } from 'react'; -import { StaticTreeDataProvider, TreeEnvironmentRef, TreeItem, TreeItemIndex, TreeRef } from 'react-complex-tree'; +import { StaticTreeDataProvider, TreeEnvironmentRef, TreeItem, TreeItemIndex } from 'react-complex-tree'; import { DocTreeNode } from '@/redux-api/docsApiType'; export interface MetaData { + childrenLoaded?: boolean; newFile?: boolean; newFolder?: boolean; rename?: boolean; @@ -16,7 +17,6 @@ export const TreeDataCtx = createContext<{ data: Record>; } | null>(null); -export const TreeRefCtx = createContext(null); export const TreeEnvRefCtx = createContext(null); export const MenuCtx = createContext<{ isEnterMenu: boolean; diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts index e25d403..baf41db 100644 --- a/client/src/redux-api/github.ts +++ b/client/src/redux-api/github.ts @@ -10,6 +10,8 @@ import type { GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice import type { PublishPlan, PublishTreeEntry, + RemoteDirectoryPage, + RemoteDirectoryRequest, RemoteWorkspaceSnapshot, WorkspaceDescriptor, } from '@markdown-editor/github-workspace'; @@ -47,6 +49,11 @@ export interface GitHubWorkspaceSettings { schemaVersion: number; } +export interface GitHubDirectoryRequest extends RemoteDirectoryRequest { + owner: string; + repo: string; +} + let octokitToken = ''; let octokitClient: Octokit | null = null; @@ -65,9 +72,46 @@ const getErrorMessage = (error: unknown) => { return 'GitHub request failed.'; }; -const getSnapshot = async ( - config: Pick, -): Promise => { +const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); + +const requestGitTree = async ( + config: Pick, + treeSha: string, + recursive = false, +) => { + const octokit = getOctokit(); + const response = await octokit.request('GET /repos/{owner}/{repo}/git/trees/{tree_sha}', { + owner: config.owner, + repo: config.repo, + tree_sha: treeSha, + recursive: recursive ? '1' : undefined, + }); + return response.data; +}; + +const getGitTree = async (config: Pick, treeSha: string) => { + const tree = await requestGitTree(config, treeSha); + if (tree.truncated) { + throw new Error('This GitHub directory is too large to load safely.'); + } + return tree.tree; +}; + +const toRemoteEntries = ( + directoryPath: string, + entries: Awaited>, +): RemoteDirectoryPage['entries'] => + entries + .filter((entry) => entry.path && entry.mode && entry.type && entry.sha) + .map((entry) => ({ + path: [directoryPath, entry.path].filter(Boolean).join('/'), + mode: entry.mode as GitMode, + type: entry.type as RemoteDirectoryPage['entries'][number]['type'], + sha: entry.sha, + size: entry.size, + })); + +const getCommitTree = async (config: Pick) => { const octokit = getOctokit(); const refResponse = await octokit.request('GET /repos/{owner}/{repo}/git/ref/{ref}', { owner: config.owner, @@ -80,29 +124,72 @@ const getSnapshot = async ( repo: config.repo, commit_sha: commitSha, }); - const treeSha = commitResponse.data.tree.sha; - const treeResponse = await octokit.request('GET /repos/{owner}/{repo}/git/trees/{tree_sha}', { - owner: config.owner, - repo: config.repo, - tree_sha: treeSha, - recursive: '1', - }); - if (treeResponse.data.truncated) { - throw new Error('This repository tree is too large to load safely in GitHub workspace mode.'); + return { baseCommitSha: commitSha, baseTreeSha: commitResponse.data.tree.sha }; +}; + +const getSnapshot = async (config: WorkspaceDescriptor): Promise => { + const { baseCommitSha, baseTreeSha } = await getCommitTree(config); + const directoryPath = cleanPath(config.docsRoot); + let directoryTreeSha: string | null = baseTreeSha; + let traversedPath = ''; + + for (const segment of directoryPath.split('/').filter(Boolean)) { + const entries = await getGitTree(config, directoryTreeSha); + const directory = entries.find((entry) => entry.path === segment); + traversedPath = [traversedPath, segment].filter(Boolean).join('/'); + if (!directory) { + return { baseCommitSha, baseTreeSha, directoryPath, directoryTreeSha: null, entries: [], complete: true }; + } + if (directory.type !== 'tree' || !directory.sha) { + throw new Error(`The configured docs root ${traversedPath} is not a directory.`); + } + directoryTreeSha = directory.sha; } - const entries = treeResponse.data.tree - .filter((entry) => entry.path && entry.mode && entry.type && entry.sha && entry.type !== 'tree') - .map((entry) => ({ - path: entry.path, - mode: entry.mode as GitMode, - type: entry.type as RemoteWorkspaceSnapshot['entries'][number]['type'], - sha: entry.sha, - size: entry.size, - })); - return { baseCommitSha: commitSha, baseTreeSha: treeSha, entries }; + if (!directoryTreeSha) { + return { baseCommitSha, baseTreeSha, directoryPath, directoryTreeSha, entries: [], complete: true }; + } + + const recursiveTree = await requestGitTree(config, directoryTreeSha, true); + if (!recursiveTree.truncated) { + return { + baseCommitSha, + baseTreeSha, + directoryPath, + directoryTreeSha, + entries: toRemoteEntries(directoryPath, recursiveTree.tree), + complete: true, + }; + } + + // GitHub does not paginate truncated recursive trees. Restart from the directory's + // direct children and let the existing subtree loader fetch descendants on demand. + const entries = await getGitTree(config, directoryTreeSha); + return { + baseCommitSha, + baseTreeSha, + directoryPath, + directoryTreeSha, + entries: toRemoteEntries(directoryPath, entries), + complete: false, + }; }; +const getDirectoryPage = async ({ + owner, + repo, + baseCommitSha, + baseTreeSha, + directoryPath, + directoryTreeSha, +}: GitHubDirectoryRequest): Promise => ({ + baseCommitSha, + baseTreeSha, + directoryPath: cleanPath(directoryPath), + directoryTreeSha, + entries: toRemoteEntries(cleanPath(directoryPath), await getGitTree({ owner, repo }, directoryTreeSha)), +}); + interface CommitTreeOptions { baseCommitSha: string; baseTreeSha: string; @@ -158,6 +245,23 @@ const commitTreeToBranch = async ({ baseCommitSha, baseTreeSha, config, entries, }); }; +const hasWorkspaceSettings = async ( + config: Pick, +): Promise => { + try { + const response = await getOctokit().rest.repos.getContent({ + owner: config.owner, + repo: config.repo, + path: WORKSPACE_SETTINGS_PATH, + ref: config.branch, + }); + return !Array.isArray(response.data) && response.data.type === 'file'; + } catch (error) { + if ((error as { status?: number }).status === 404) return false; + throw error; + } +}; + const initializeWorkspace = async ( config: Pick, ): Promise => { @@ -168,7 +272,7 @@ const initializeWorkspace = async ( let isInitializing: boolean; try { snapshot = await getSnapshot(config); - isInitializing = !snapshot.entries.some((entry) => entry.path === WORKSPACE_SETTINGS_PATH); + isInitializing = !(await hasWorkspaceSettings(config)); } catch (error) { const status = (error as { status?: number }).status; if (status !== 404 && status !== 409) throw error; @@ -308,6 +412,15 @@ export const githubApi = createApi({ }, providesTags: ['Workspace'], }), + getGitHubDirectory: builder.query({ + queryFn: async (request) => { + try { + return { data: await getDirectoryPage(request) }; + } catch (error) { + return { error: { message: getErrorMessage(error) } }; + } + }, + }), getGitHubBlob: builder.query({ queryFn: async ({ owner, repo, sha }) => { try { @@ -369,7 +482,7 @@ export const githubApi = createApi({ publishGitHubWorkspace: builder.mutation({ queryFn: async ({ config, plan, title, body }) => { try { - const current = await getSnapshot(config); + const current = await getCommitTree(config); if (current.baseCommitSha !== plan.baseCommitSha) { throw new Error( 'The selected branch changed on GitHub. Rebase or discard local changes before publishing.', @@ -394,6 +507,7 @@ export const githubApi = createApi({ export const { useCreateGitHubRepositoryMutation, + useLazyGetGitHubDirectoryQuery, useGetGitHubBlobQuery, useGetGitHubWorkspaceSettingsQuery, useInitializeGitHubWorkspaceMutation, diff --git a/client/src/utils/hooks/reduxHooks.ts b/client/src/utils/hooks/reduxHooks.ts index 5c329b8..6417e64 100644 --- a/client/src/utils/hooks/reduxHooks.ts +++ b/client/src/utils/hooks/reduxHooks.ts @@ -148,6 +148,8 @@ export const useDeleteTab = () => { const lastTab = newTabs[newTabs.length - 1]; if (lastTab.type === 'workspace') { void navigate(`/article/${lastTab.ident}`); + } else if (lastTab.type === 'draft') { + void navigate(`/draft/${lastTab.ident}`); } else if (lastTab.type === 'internal') { void navigate(`/internal/${lastTab.ident}`); } diff --git a/client/src/utils/hooks/workspaceHooks.ts b/client/src/utils/hooks/workspaceHooks.ts index 3a74017..1cf4173 100644 --- a/client/src/utils/hooks/workspaceHooks.ts +++ b/client/src/utils/hooks/workspaceHooks.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/require-await */ /* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import { QueryStatus, skipToken } from '@reduxjs/toolkit/query'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useSelector } from 'react-redux'; import type { @@ -26,7 +26,7 @@ import { useModifyDocNameMutation, useUpdateDocMutation, } from '@/redux-api/docs'; -import { useGetGitHubBlobQuery, useLoadGitHubWorkspaceQuery } from '@/redux-api/github'; +import { useGetGitHubBlobQuery, useLazyGetGitHubDirectoryQuery, useLoadGitHubWorkspaceQuery } from '@/redux-api/github'; import { getGitHubWorkspaceKey, selectGithubWorkspace, @@ -68,6 +68,62 @@ const listGitHubSubItems = (config: GitHubWorkspaceConfig, logicalFolderPath = ' .filter((entry): entry is DocTreeNode => Boolean(entry)); }; +const getParentRepositoryPath = (path: string) => path.split('/').filter(Boolean).slice(0, -1).join('/'); + +const useGitHubDirectoryLoader = () => { + const workspace = useSelector(selectGithubWorkspace); + const [getDirectoryPage] = useLazyGetGitHubDirectoryQuery(); + + const loadDirectory = useCallback( + async (repositoryPath: string) => { + if (workspace.mode !== 'github') return; + const request = githubWorkspaceStore.getDirectoryLoadRequest(repositoryPath); + if (!request) return; + const page = await getDirectoryPage( + { owner: workspace.config.owner, repo: workspace.config.repo, ...request }, + true, + ).unwrap(); + await githubWorkspaceStore.attachRemoteDirectory(page); + }, + [getDirectoryPage, workspace.config.owner, workspace.config.repo, workspace.mode], + ); + + const loadDirectoryTree = useCallback( + async (repositoryPath: string) => { + const queue = [repositoryPath]; + const visited = new Set(); + while (queue.length) { + const path = queue.shift()!; + if (visited.has(path)) continue; + visited.add(path); + await loadDirectory(path); + githubWorkspaceStore + .listDirectory(path) + .filter((entry) => entry.kind === 'directory') + .forEach((entry) => queue.push(entry.path)); + } + }, + [loadDirectory], + ); + + const loadDirectoryPath = useCallback( + async (repositoryPath: string) => { + const workspaceRoot = getRepositoryDocPath(workspace.config, '', false); + const relativePath = repositoryPath.slice(workspaceRoot.length).split('/').filter(Boolean); + let currentPath = workspaceRoot; + + await loadDirectory(currentPath); + for (const segment of relativePath) { + currentPath = [currentPath, segment].filter(Boolean).join('/'); + await loadDirectory(currentPath); + } + }, + [loadDirectory, workspace.config], + ); + + return { loadDirectory, loadDirectoryPath, loadDirectoryTree }; +}; + /** Activates the keyed package store, then attaches the RTK-fetched remote manifest. */ export const useGitHubWorkspaceSync = () => { const workspace = useSelector(selectGithubWorkspace); @@ -168,15 +224,26 @@ export const useWorkspaceRootItemsQuery = () => { export const useLazyWorkspaceSubItemsQuery = () => { const workspace = useSelector(selectGithubWorkspace); const [localTrigger] = useLazyGetDocSubItemsQuery(); + const { loadDirectory } = useGitHubDirectoryLoader(); const trigger = useCallback( async (params: { folderDocPath?: string } = {}) => { if (workspace.mode === 'local') return localTrigger(params); - return { - data: listGitHubSubItems(workspace.config, params.folderDocPath), - status: QueryStatus.fulfilled, - }; + try { + const repositoryPath = getRepositoryDocPath(workspace.config, params.folderDocPath ?? '', false); + await loadDirectory(repositoryPath); + return { + data: listGitHubSubItems(workspace.config, params.folderDocPath), + status: QueryStatus.fulfilled, + }; + } catch (error) { + return { + data: [], + error, + status: QueryStatus.rejected, + }; + } }, - [localTrigger, workspace.config, workspace.mode], + [loadDirectory, localTrigger, workspace.config, workspace.mode], ); return [trigger] as const; }; @@ -191,6 +258,10 @@ const getDefaultArticle = (path: string, content: string): Article => ({ export const useWorkspaceDocQuery = (logicalPath: string) => { const workspace = useSelector(selectGithubWorkspace); const snapshot = useGitHubWorkspaceSnapshot(); + const { loadDirectoryPath } = useGitHubDirectoryLoader(); + + // Local documents can be queried directly. GitHub documents are discovered through the + // lazily populated workspace tree, so their parent directories may need loading first. const localQuery = useGetDocQuery(logicalPath, { skip: workspace.mode === 'github' }); const configured = isConfigured(workspace.config); const activeWorkspace = snapshot.descriptor @@ -202,7 +273,11 @@ export const useWorkspaceDocQuery = (logicalPath: string) => { : skipToken, ); const repositoryPath = getRepositoryDocPath(workspace.config, logicalPath, true); + const parentRepositoryPath = getParentRepositoryPath(repositoryPath); const source = githubWorkspaceStore.getFileSource(repositoryPath); + + // Once the file entry is known, unchanged remote files load by blob SHA while locally + // modified files already expose their content through the workspace store. const blobQuery = useGetGitHubBlobQuery( workspace.mode === 'github' && source?.kind === 'remote' && source.sha ? { owner: workspace.config.owner, repo: workspace.config.repo, sha: source.sha } @@ -220,30 +295,83 @@ export const useWorkspaceDocQuery = (logicalPath: string) => { lastLoadedGithubArticleRef.current?.key === githubDocumentKey ? lastLoadedGithubArticleRef.current.article : undefined; + // Keep the last loaded document while a file operation updates its tab or route. const displayedGithubArticle = githubArticle ?? cachedGithubArticle; const missingDocumentError = useMemo(() => new Error(`The file ${logicalPath} does not exist.`), [logicalPath]); - if (workspace.mode === 'local') return localQuery; + // On a fresh IndexedDB workspace only the repository root is available. Resolve each + // directory in the requested path before treating an absent file entry as deleted. + // Including the base commit in the key forces a fresh lookup after a remote refresh. + const resolutionKey = `${githubDocumentKey}:${snapshot.baseCommitSha}`; + const [pathResolution, setPathResolution] = useState<{ + error?: unknown; + key: string; + status: 'loading' | 'resolved'; + }>(); + const canResolvePath = + workspace.mode === 'github' && + activeWorkspace && + snapshot.persistenceLoaded && + Boolean(snapshot.baseCommitSha) && + !source && + !cachedGithubArticle; + + useEffect(() => { + if (!canResolvePath) return; + + let cancelled = false; + setPathResolution({ key: resolutionKey, status: 'loading' }); + void loadDirectoryPath(parentRepositoryPath).then( + () => { + if (!cancelled) setPathResolution({ key: resolutionKey, status: 'resolved' }); + }, + (error: unknown) => { + if (!cancelled) setPathResolution({ error, key: resolutionKey, status: 'resolved' }); + }, + ); + + return () => { + cancelled = true; + }; + }, [canResolvePath, loadDirectoryPath, parentRepositoryPath, resolutionKey]); + + const isResolvingPath = + canResolvePath && (pathResolution?.key !== resolutionKey || pathResolution.status === 'loading'); + const pathResolutionError = pathResolution?.key === resolutionKey ? pathResolution.error : undefined; + + // Preserve the RTK Query contract for local workspaces and while GitHub initialization + // is incomplete. A missing-path decision is only valid after initialization and lookup. + if (workspace.mode === 'local') return { ...localQuery, isMissing: false }; if (!activeWorkspace || !snapshot.persistenceLoaded || !snapshot.baseCommitSha) { return { data: undefined, error: workspaceQuery.error, + isMissing: false, isSuccess: false, isFetching: !activeWorkspace || !snapshot.persistenceLoaded || !workspaceQuery.isError, }; } + + // A path is genuinely missing only when all parent directories loaded successfully and + // neither the workspace store nor the last stable render contains the requested file. + const isMissing = !source && !cachedGithubArticle && !isResolvingPath && !pathResolutionError; return { data: displayedGithubArticle, - error: source || cachedGithubArticle ? blobQuery.error : missingDocumentError, + error: + source || cachedGithubArticle + ? blobQuery.error + : pathResolutionError ?? (isMissing ? missingDocumentError : undefined), + isMissing, isSuccess: displayedGithubArticle !== undefined, - isFetching: Boolean(source && githubArticle === undefined && blobQuery.isFetching), + isFetching: isResolvingPath || Boolean(source && githubArticle === undefined && blobQuery.isFetching), }; }; export const useCreateWorkspaceDoc = () => { const workspace = useSelector(selectGithubWorkspace); const [localCreate] = useCreateDocMutation(); + const { loadDirectory } = useGitHubDirectoryLoader(); return async (payload: CreateDocPayload): Promise => { if (workspace.mode === 'local') return localCreate(payload).unwrap(); const logicalParts = denormalizePath(payload.filePath).filter(Boolean); @@ -251,6 +379,7 @@ export const useCreateWorkspaceDoc = () => { logicalParts[logicalParts.length - 1] = name; const logicalPath = normalizePath(logicalParts); const repositoryPath = getRepositoryDocPath(workspace.config, logicalPath, payload.isFile); + await loadDirectory(getParentRepositoryPath(repositoryPath)); if (payload.isFile) await githubWorkspaceStore.createFile(repositoryPath); else await githubWorkspaceStore.createDirectory(repositoryPath); return { id: `github-${logicalParts.join('-')}`, name, isFile: payload.isFile, path: logicalParts }; @@ -272,10 +401,13 @@ export const useUpdateWorkspaceDoc = () => { export const useDeleteWorkspaceDocs = () => { const workspace = useSelector(selectGithubWorkspace); const [localDelete] = useDeleteDocMutation(); + const { loadDirectoryTree } = useGitHubDirectoryLoader(); return async (payload: DeleteDocPayload) => { if (workspace.mode === 'local') return localDelete(payload).unwrap(); for (const item of payload) { - await githubWorkspaceStore.delete(getRepositoryDocPath(workspace.config, item.filePath, item.isFile)); + const repositoryPath = getRepositoryDocPath(workspace.config, item.filePath, item.isFile); + if (!item.isFile) await loadDirectoryTree(repositoryPath); + await githubWorkspaceStore.delete(repositoryPath); } }; }; @@ -283,6 +415,7 @@ export const useDeleteWorkspaceDocs = () => { export const useCopyCutWorkspaceDocs = () => { const workspace = useSelector(selectGithubWorkspace); const [localCopyCut] = useCopyCutDocMutation(); + const { loadDirectory, loadDirectoryTree } = useGitHubDirectoryLoader(); return async (payload: CopyCutDocPayload, onApplied?: () => void) => { if (workspace.mode === 'local') { const result = await localCopyCut(payload).unwrap(); @@ -292,6 +425,8 @@ export const useCopyCutWorkspaceDocs = () => { for (const item of payload) { const source = getRepositoryDocPath(workspace.config, item.copyCutPath, item.isFile); const destination = getRepositoryDocPath(workspace.config, item.pastePath, item.isFile); + if (!item.isFile) await loadDirectoryTree(source); + await loadDirectory(getParentRepositoryPath(destination)); if (item.isCopy) await githubWorkspaceStore.copy(source, destination); else await githubWorkspaceStore.move(source, destination); } diff --git a/packages/github-workspace/README.md b/packages/github-workspace/README.md index 6267d96..82608d1 100644 --- a/packages/github-workspace/README.md +++ b/packages/github-workspace/README.md @@ -4,7 +4,7 @@ Browser-side Git workspace state manager. It keeps file changes in IndexedDB and ## State model -The workspace keeps three complete trees: +The workspace keeps three trees containing the remote entries known so far plus all local changes: ```text baseTree Remote commit currently used as the base @@ -12,6 +12,15 @@ stagedTree Files included in the next publish workingTree Files and folders currently shown in the editor ``` +Initial loading first requests the configured docs root recursively. A complete response marks every returned directory +as loaded. If GitHub truncates that response, initialization restarts with the docs root's direct children and nested +directories are loaded one level at a time. The menu and file operations use the same directory-loader API in both cases; +loading is simply a no-op for directories already present in a complete snapshot. + +Directory Git tree SHAs and loaded state are persisted separately. An entry that has not been fetched is absent from all +three trees and therefore never appears as a deletion. Publishing uses GitHub's `base_tree`, so unloaded remote entries +remain unchanged. + Git status is derived from differences between these trees: ```ts diff --git a/packages/github-workspace/src/git-status.ts b/packages/github-workspace/src/git-status.ts index 4884f58..ef71131 100644 --- a/packages/github-workspace/src/git-status.ts +++ b/packages/github-workspace/src/git-status.ts @@ -68,7 +68,7 @@ export const createBaseTree = (descriptor: WorkspaceDescriptor, entries: RemoteT tree.entries[entry.path] = { id: `base:${entry.path}`, path: entry.path, - kind: 'file', + kind: entry.type === 'tree' ? 'directory' : 'file', mode: entry.mode, type: entry.type, sha: entry.sha, @@ -153,7 +153,7 @@ export const applyChangesToTree = (baseTree: TreeState, changes: WorkspaceChange .filter((entry) => entry.kind === 'directory') .forEach((entry) => { const hasDescendant = Object.keys(result.entries).some((path) => path.startsWith(`${entry.path}/`)); - if (!hasDescendant) delete result.entries[entry.path]; + if (!hasDescendant && !entry.sha && !entry.operationMetadata) delete result.entries[entry.path]; }); return result; }; diff --git a/packages/github-workspace/src/index.ts b/packages/github-workspace/src/index.ts index 7ff450d..9c9a0a6 100644 --- a/packages/github-workspace/src/index.ts +++ b/packages/github-workspace/src/index.ts @@ -41,6 +41,9 @@ import type { OperationMetadata, PathMapping, PublishPlan, + RemoteDirectoryPage, + RemoteDirectoryRequest, + RemoteDirectoryState, RemoteWorkspaceSnapshot, TreeState, WorkspaceDescriptor, @@ -70,6 +73,8 @@ export class GitWorkspaceStore { private workingTree = emptyTree(); + private remoteDirectories: Record = {}; + private workspaceVersion = 0; private persistenceLoaded = false; @@ -140,6 +145,7 @@ export class GitWorkspaceStore { this.stagedTree = applyChangesToTree(this.baseTree, persisted.stagedChanges, this.descriptor.docsRoot); this.workingTree = applyChangesToTree(this.stagedTree, persisted.unstagedChanges, this.descriptor.docsRoot); restoreLocalDirectories(this.workingTree, persisted.localDirectories, this.descriptor.docsRoot); + this.remoteDirectories = this._cloneRemoteDirectories(persisted.remoteDirectories); this.workspaceVersion = persisted.workspaceVersion; } else { this.baseCommitSha = ''; @@ -147,6 +153,7 @@ export class GitWorkspaceStore { this.baseTree = emptyTree(); this.stagedTree = emptyTree(); this.workingTree = emptyTree(); + this.remoteDirectories = {}; this.workspaceVersion = 0; } this.persistenceLoaded = true; @@ -165,6 +172,7 @@ export class GitWorkspaceStore { this.baseTree = emptyTree(); this.stagedTree = emptyTree(); this.workingTree = emptyTree(); + this.remoteDirectories = {}; this.workspaceVersion = 0; this.persistenceLoaded = false; this.movedPathAliases.clear(); @@ -179,6 +187,11 @@ export class GitWorkspaceStore { const expectedKey = this.workspaceKey; return this._enqueueOperation(async () => { if (!this.descriptor || this.workspaceKey !== expectedKey) return; + if (this.baseCommitSha === snapshot.baseCommitSha) { + if (snapshot.complete) await this._attachCompleteRemoteSnapshot(snapshot); + else await this._attachRemoteDirectory(snapshot); + return; + } const remote = createBaseTree(this.descriptor, snapshot.entries); const pending = getUnstagedChanges(this.stagedTree, this.workingTree).length > 0 || @@ -193,6 +206,7 @@ export class GitWorkspaceStore { this.stagedTree = cloneTree(remote); this.workingTree = cloneTree(remote); restoreLocalDirectories(this.workingTree, localDirectories, this.descriptor.docsRoot); + this.remoteDirectories = this._getInitialRemoteDirectories(snapshot); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; this.workspaceVersion += 1; @@ -208,6 +222,26 @@ export class GitWorkspaceStore { return listDirectoryEntries(this.workingTree, path); } + /** Returns the immutable Git tree locator needed to load a directory, or null when it is already available locally. */ + public getDirectoryLoadRequest(path: string): RemoteDirectoryRequest | null { + const directoryPath = cleanPath(path); + const state = this.remoteDirectories[directoryPath]; + if (!state || state.loaded || !state.treeSha || !this.baseCommitSha || !this.baseTreeSha) return null; + return { + baseCommitSha: this.baseCommitSha, + baseTreeSha: this.baseTreeSha, + directoryPath, + directoryTreeSha: state.treeSha, + }; + } + + /** Merges one non-recursive remote directory page without replacing staged or unstaged local changes. */ + public async attachRemoteDirectory(page: RemoteDirectoryPage) { + return this._enqueueOperation(async () => { + await this._attachRemoteDirectory(page); + }); + } + /** * Returns local content for an edited file or a SHA for an unchanged remote file. * Moved-path aliases are resolved automatically; missing paths and directories return `undefined`. @@ -366,12 +400,26 @@ export class GitWorkspaceStore { const descriptor = this._getOpenDescriptor(); const previousStagedTree = this.stagedTree; const localDirectories = getLocalDirectories(this.workingTree); - const nextBase = createBaseTree(descriptor, snapshot.entries); + // A complete recursive response is authoritative. A truncated fallback contains only + // direct root entries, so keep the staged tree's already-known descendants in that case. + const nextBase = snapshot.complete ? createBaseTree(descriptor, snapshot.entries) : cloneTree(previousStagedTree); + if (!snapshot.complete) { + nextBase.deletedOperationMetadata = {}; + Object.values(nextBase.entries).forEach((entry) => { + delete entry.operationMetadata; + }); + const refreshedRoot = createBaseTree(descriptor, snapshot.entries); + const directEntryPaths = new Set(snapshot.entries.map((entry) => cleanPath(entry.path))); + Object.entries(refreshedRoot.entries).forEach(([path, entry]) => { + if (directEntryPaths.has(path) || !nextBase.entries[path]) nextBase.entries[path] = entry; + }); + } this.baseTree = nextBase; this.stagedTree = cloneTree(nextBase); // Keep unstaged files and local-only empty folders after publishing. this.workingTree = replayChangesOntoTree(previousStagedTree, this.workingTree, nextBase, descriptor.docsRoot); restoreLocalDirectories(this.workingTree, localDirectories, descriptor.docsRoot); + this.remoteDirectories = this._getInitialRemoteDirectories(snapshot); this.baseCommitSha = snapshot.baseCommitSha; this.baseTreeSha = snapshot.baseTreeSha; this.publishingToken = ''; @@ -474,6 +522,7 @@ export class GitWorkspaceStore { baseTree: this.baseTree, stagedTree: this.stagedTree, workingTree: this.workingTree, + remoteDirectories: this.remoteDirectories, workspaceVersion: this.workspaceVersion, }), ); @@ -487,4 +536,81 @@ export class GitWorkspaceStore { listener(); }); } + + private async _attachCompleteRemoteSnapshot(snapshot: RemoteWorkspaceSnapshot) { + if (!this.descriptor || !this.baseCommitSha) return; + if (snapshot.baseCommitSha !== this.baseCommitSha || snapshot.baseTreeSha !== this.baseTreeSha) { + throw new Error('The GitHub tree response belongs to an outdated workspace revision.'); + } + const alreadyAttached = snapshot.entries.every((entry) => { + const path = cleanPath(entry.path); + const current = this.baseTree.entries[path]; + const directoryLoaded = entry.type !== 'tree' || this.remoteDirectories[path]?.loaded; + return ( + current?.mode === entry.mode && current.type === entry.type && current.sha === entry.sha && directoryLoaded + ); + }); + if (alreadyAttached) return; + + const stagedChanges = getStagedChanges(this.baseTree, this.stagedTree); + const unstagedChanges = getUnstagedChanges(this.stagedTree, this.workingTree); + const localDirectories = getLocalDirectories(this.workingTree); + const remote = createBaseTree(this.descriptor, snapshot.entries); + this.baseTree = remote; + this.stagedTree = applyChangesToTree(remote, stagedChanges, this.descriptor.docsRoot); + this.workingTree = applyChangesToTree(this.stagedTree, unstagedChanges, this.descriptor.docsRoot); + restoreLocalDirectories(this.workingTree, localDirectories, this.descriptor.docsRoot); + this.remoteDirectories = this._getInitialRemoteDirectories(snapshot); + await this._saveAndNotify(); + } + + private async _attachRemoteDirectory(page: RemoteDirectoryPage) { + if (!this.descriptor || !this.baseCommitSha) return; + if (page.baseCommitSha !== this.baseCommitSha || page.baseTreeSha !== this.baseTreeSha) { + throw new Error('The GitHub directory response belongs to an outdated workspace revision.'); + } + const directoryPath = cleanPath(page.directoryPath); + const current = this.remoteDirectories[directoryPath]; + if (current?.loaded) return; + if (current && current.treeSha !== page.directoryTreeSha) { + throw new Error(`The GitHub directory ${directoryPath || '/'} changed while it was loading.`); + } + + const stagedChanges = getStagedChanges(this.baseTree, this.stagedTree); + const unstagedChanges = getUnstagedChanges(this.stagedTree, this.workingTree); + const localDirectories = getLocalDirectories(this.workingTree); + const additions = createBaseTree(this.descriptor, page.entries); + const directEntryPaths = new Set(page.entries.map((entry) => cleanPath(entry.path))); + const nextBase = cloneTree(this.baseTree); + Object.entries(additions.entries).forEach(([path, entry]) => { + if (directEntryPaths.has(path) || !nextBase.entries[path]) nextBase.entries[path] = entry; + }); + this.baseTree = nextBase; + this.stagedTree = applyChangesToTree(nextBase, stagedChanges, this.descriptor.docsRoot); + this.workingTree = applyChangesToTree(this.stagedTree, unstagedChanges, this.descriptor.docsRoot); + restoreLocalDirectories(this.workingTree, localDirectories, this.descriptor.docsRoot); + this.remoteDirectories[directoryPath] = { treeSha: page.directoryTreeSha, loaded: true }; + page.entries + .filter((entry) => entry.type === 'tree') + .forEach((entry) => { + this.remoteDirectories[cleanPath(entry.path)] ??= { treeSha: entry.sha, loaded: false }; + }); + await this._saveAndNotify(); + } + + private _getInitialRemoteDirectories(snapshot: RemoteWorkspaceSnapshot) { + const directories: Record = { + [cleanPath(snapshot.directoryPath)]: { treeSha: snapshot.directoryTreeSha, loaded: true }, + }; + snapshot.entries + .filter((entry) => entry.type === 'tree') + .forEach((entry) => { + directories[cleanPath(entry.path)] = { treeSha: entry.sha, loaded: snapshot.complete }; + }); + return directories; + } + + private _cloneRemoteDirectories(directories: Record) { + return Object.fromEntries(Object.entries(directories).map(([path, state]) => [path, { ...state }])); + } } diff --git a/packages/github-workspace/src/persistence.ts b/packages/github-workspace/src/persistence.ts index 8334bbb..233735b 100644 --- a/packages/github-workspace/src/persistence.ts +++ b/packages/github-workspace/src/persistence.ts @@ -4,6 +4,7 @@ import { cloneTree, getLocalDirectories, getStagedChanges, getUnstagedChanges } import type { IndexedDbWorkspacePersistenceOptions, PersistedWorkspace, + RemoteDirectoryState, TreeState, WorkspaceDescriptor, WorkspacePersistence, @@ -16,6 +17,7 @@ interface PersistedWorkspaceInput { baseTree: TreeState; stagedTree: TreeState; workingTree: TreeState; + remoteDirectories: Record; workspaceVersion: number; } @@ -28,6 +30,9 @@ export const createPersistedWorkspace = (input: PersistedWorkspaceInput): Persis unstagedChanges: getUnstagedChanges(input.stagedTree, input.workingTree), stagedChanges: getStagedChanges(input.baseTree, input.stagedTree), localDirectories: getLocalDirectories(input.workingTree), + remoteDirectories: Object.fromEntries( + Object.entries(input.remoteDirectories).map(([path, state]) => [path, { ...state }]), + ), workspaceVersion: input.workspaceVersion, }); diff --git a/packages/github-workspace/src/types.ts b/packages/github-workspace/src/types.ts index 10d7479..e42af15 100644 --- a/packages/github-workspace/src/types.ts +++ b/packages/github-workspace/src/types.ts @@ -36,12 +36,31 @@ export interface RemoteTreeEntry { size?: number; } -export interface RemoteWorkspaceSnapshot { +export interface RemoteDirectoryPage { baseCommitSha: string; baseTreeSha: string; + directoryPath: string; + directoryTreeSha: string | null; entries: RemoteTreeEntry[]; } +export interface RemoteWorkspaceSnapshot extends RemoteDirectoryPage { + /** True when entries contain every descendant of directoryPath. */ + complete: boolean; +} + +export interface RemoteDirectoryState { + treeSha: string | null; + loaded: boolean; +} + +export interface RemoteDirectoryRequest { + baseCommitSha: string; + baseTreeSha: string; + directoryPath: string; + directoryTreeSha: string; +} + export interface OperationMetadata { /** Connects every path changed by one user action. */ groupId: string; @@ -93,7 +112,7 @@ export interface WorkspaceSnapshot { baseCommitSha: string; /** Identifies the Git tree used to create the next commit. */ baseTreeSha: string; - /** Increases whenever workspace data changes. */ + /** Increases when local changes or a remote-root replacement require consumers to rebuild their views. */ workspaceVersion: number; /** True after browser storage has been read. */ persistenceLoaded: boolean; @@ -137,6 +156,7 @@ export interface PersistedWorkspace { unstagedChanges: WorkspaceChange[]; stagedChanges: WorkspaceChange[]; localDirectories: WorkspaceEntry[]; + remoteDirectories: Record; workspaceVersion: number; } From 1d68a5210e49c100e42c7acc49815e883ab16cba Mon Sep 17 00:00:00 2001 From: s-elo Date: Wed, 2 Sep 2026 15:36:37 +0800 Subject: [PATCH 13/17] feat: extract editor core as a standalone package --- client/package.json | 27 +- client/src/components/Editor/DraftEditor.tsx | 32 +- client/src/components/Editor/Editor.scss | 7 +- client/src/components/Editor/Editor.tsx | 37 +- .../src/components/Editor/MilkdownEditor.tsx | 158 --------- .../Editor/components/TooltipInput/index.tsx | 47 --- .../components/Editor/configs/uploadConfig.ts | 54 --- .../src/components/Editor/mountedAddons.tsx | 321 ----------------- .../plugin-container/ContainerTitle.tsx | 147 -------- .../plugins/plugin-container/index.scss | 78 ----- .../Editor/plugins/plugin-container/index.ts | 160 --------- .../Editor/plugins/plugin-heading/Anchor.tsx | 28 -- .../Editor/plugins/plugin-heading/index.ts | 87 ----- .../plugins/plugin-highlight/commnads.ts | 49 --- .../Editor/plugins/plugin-highlight/index.ts | 82 ----- .../Editor/plugins/plugin-highlight/slices.ts | 67 ---- .../plugin-highlight/view/ColorPicker.scss | 54 --- .../Editor/plugins/plugin-iframe/index.scss | 70 ---- .../Editor/plugins/plugin-iframe/index.ts | 90 ----- .../EditorContainer/EditorContainer.tsx | 28 +- package.json | 2 +- packages/editor-core/README.md | 66 ++++ packages/editor-core/package.json | 44 +++ packages/editor-core/src/MilkdownEditor.tsx | 147 ++++++++ .../src/components}/ColorPicker.tsx | 34 +- packages/editor-core/src/components/Icon.scss | 18 + packages/editor-core/src/components/Icon.tsx | 65 ++++ .../src}/components/TooltipInput/index.scss | 11 +- .../src/components/TooltipInput/index.tsx | 38 ++ packages/editor-core/src/components/index.ts | 6 + .../editor-core/src}/configs/crepeFeatures.ts | 48 +-- packages/editor-core/src/configs/index.ts | 2 + .../editor-core/src/configs/uploadConfig.ts | 47 +++ .../editor-core/src}/crepe.ts | 44 ++- packages/editor-core/src/global.d.ts | 2 + packages/editor-core/src/index.ts | 37 ++ packages/editor-core/src/mountedAddons.tsx | 170 +++++++++ packages/editor-core/src/plugins/index.ts | 4 + .../plugin-container/ContainerTitle.tsx | 123 +++++++ .../src/plugins/plugin-container/index.scss | 30 ++ .../src/plugins/plugin-container/index.ts | 116 +++++++ .../src}/plugins/plugin-container/types.ts | 0 .../src/plugins/plugin-heading/Anchor.tsx | 23 ++ .../src}/plugins/plugin-heading/heading.scss | 44 +-- .../src/plugins/plugin-heading/index.ts | 79 +++++ .../src/plugins/plugin-highlight/commnads.ts | 39 +++ .../plugins/plugin-highlight/configration.ts | 10 +- .../src/plugins/plugin-highlight/index.ts | 58 ++++ .../plugin-highlight/remark-mark-color.ts | 64 +--- .../src/plugins/plugin-highlight/slices.ts | 32 ++ .../src}/plugins/plugin-highlight/tooltip.ts | 0 .../plugin-highlight/view/ColorPicker.scss | 20 ++ .../plugin-highlight/view/ColorPicker.tsx | 2 + .../plugins/plugin-highlight/view/index.ts | 99 +----- .../src}/plugins/plugin-iframe/IframeView.tsx | 22 +- .../src/plugins/plugin-iframe/index.scss | 27 ++ .../src/plugins/plugin-iframe/index.ts | 72 ++++ packages/editor-core/src/styles/editor.scss | 57 +++ packages/editor-core/src/styles/index.scss | 8 + .../editor-core/src/styles/variables.scss | 19 + packages/editor-core/src/utils.ts | 34 ++ packages/editor-core/tsconfig.json | 14 + pnpm-lock.yaml | 324 ++++++------------ tsconfig.eslint.json | 1 + 64 files changed, 1679 insertions(+), 2046 deletions(-) delete mode 100644 client/src/components/Editor/MilkdownEditor.tsx delete mode 100644 client/src/components/Editor/components/TooltipInput/index.tsx delete mode 100644 client/src/components/Editor/mountedAddons.tsx delete mode 100644 client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx delete mode 100644 client/src/components/Editor/plugins/plugin-container/index.scss delete mode 100644 client/src/components/Editor/plugins/plugin-container/index.ts delete mode 100644 client/src/components/Editor/plugins/plugin-heading/Anchor.tsx delete mode 100644 client/src/components/Editor/plugins/plugin-heading/index.ts delete mode 100644 client/src/components/Editor/plugins/plugin-highlight/commnads.ts delete mode 100644 client/src/components/Editor/plugins/plugin-highlight/index.ts delete mode 100644 client/src/components/Editor/plugins/plugin-highlight/slices.ts delete mode 100644 client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss delete mode 100644 client/src/components/Editor/plugins/plugin-iframe/index.scss delete mode 100644 client/src/components/Editor/plugins/plugin-iframe/index.ts create mode 100644 packages/editor-core/README.md create mode 100644 packages/editor-core/package.json create mode 100644 packages/editor-core/src/MilkdownEditor.tsx rename {client/src/components/Editor/plugins/plugin-highlight/view => packages/editor-core/src/components}/ColorPicker.tsx (57%) create mode 100644 packages/editor-core/src/components/Icon.scss create mode 100644 packages/editor-core/src/components/Icon.tsx rename {client/src/components/Editor => packages/editor-core/src}/components/TooltipInput/index.scss (66%) create mode 100644 packages/editor-core/src/components/TooltipInput/index.tsx create mode 100644 packages/editor-core/src/components/index.ts rename {client/src/components/Editor => packages/editor-core/src}/configs/crepeFeatures.ts (82%) create mode 100644 packages/editor-core/src/configs/index.ts create mode 100644 packages/editor-core/src/configs/uploadConfig.ts rename {client/src/components/Editor => packages/editor-core/src}/crepe.ts (53%) create mode 100644 packages/editor-core/src/global.d.ts create mode 100644 packages/editor-core/src/index.ts create mode 100644 packages/editor-core/src/mountedAddons.tsx create mode 100644 packages/editor-core/src/plugins/index.ts create mode 100644 packages/editor-core/src/plugins/plugin-container/ContainerTitle.tsx create mode 100644 packages/editor-core/src/plugins/plugin-container/index.scss create mode 100644 packages/editor-core/src/plugins/plugin-container/index.ts rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-container/types.ts (100%) create mode 100644 packages/editor-core/src/plugins/plugin-heading/Anchor.tsx rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-heading/heading.scss (52%) create mode 100644 packages/editor-core/src/plugins/plugin-heading/index.ts create mode 100644 packages/editor-core/src/plugins/plugin-highlight/commnads.ts rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-highlight/configration.ts (73%) create mode 100644 packages/editor-core/src/plugins/plugin-highlight/index.ts rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-highlight/remark-mark-color.ts (68%) create mode 100644 packages/editor-core/src/plugins/plugin-highlight/slices.ts rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-highlight/tooltip.ts (100%) create mode 100644 packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.scss create mode 100644 packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.tsx rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-highlight/view/index.ts (58%) rename {client/src/components/Editor => packages/editor-core/src}/plugins/plugin-iframe/IframeView.tsx (73%) create mode 100644 packages/editor-core/src/plugins/plugin-iframe/index.scss create mode 100644 packages/editor-core/src/plugins/plugin-iframe/index.ts create mode 100644 packages/editor-core/src/styles/editor.scss create mode 100644 packages/editor-core/src/styles/index.scss create mode 100644 packages/editor-core/src/styles/variables.scss create mode 100644 packages/editor-core/src/utils.ts create mode 100644 packages/editor-core/tsconfig.json diff --git a/client/package.json b/client/package.json index eaa32f4..1a74edb 100644 --- a/client/package.json +++ b/client/package.json @@ -9,55 +9,32 @@ "type-check": "tsc --noEmit" }, "dependencies": { + "@markdown-editor/core": "workspace:*", "@markdown-editor/github-workspace": "workspace:*", "@codemirror/lang-markdown": "0.19.6", - "@codemirror/language": "^6.11.3", "@codemirror/language-data": "0.19.2", - "@emotion/css": "11.13.5", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@milkdown/crepe": "7.21.2", - "@milkdown/kit": "7.21.2", - "@milkdown/react": "7.21.2", - "@milkdown/utils": "7.21.2", "@mui/icons-material": "^7.2.0", "@octokit/rest": "^22.0.1", "@reduxjs/toolkit": "^1.7.1", - "@uiw/codemirror-theme-github": "^4.24.2", "@uiw/react-codemirror": "4.5.3", "@uiw/react-split": "^5.9.3", - "clipboard": "^2.0.11", - "mdast-util-from-markdown": "^2.0.2", - "mdast-util-to-markdown": "^2.1.2", - "mermaid": "^11.11.0", - "micromark-extension-highlight-mark": "^1.2.0", - "micromark-util-types": "^2.0.2", "primeicons": "^7.0.0", "primereact": "^10.9.6", - "prosemirror-inputrules": "1.5.1", "react": "19.1.0", "react-complex-tree": "^2.6.0", "react-dom": "19.1.0", "react-redux": "^9.2.0", - "react-router-dom": "^7.6.1", - "refractor": "4.9.0", - "remark-directive": "^4.0.0", - "remark-highlight-mark": "^1.4.0", - "unified": "^11.0.5", - "unist-util-add": "^1.2.0", - "unist-util-visit": "^5.0.0", - "uuid": "^13.0.0" + "react-router-dom": "^7.6.1" }, "devDependencies": { "@rsbuild/core": "^1.3.21", - "@rsbuild/plugin-less": "^1.2.4", "@rsbuild/plugin-react": "^1.3.1", "@rsbuild/plugin-sass": "^1.3.1", - "@types/mdast": "^4.0.4", "@types/node": "^24.10.1", "@types/react": "^19.1.6", "@types/react-dom": "^19.1.5", - "@types/react-redux": "7.1.34", "typescript": "^7.0.2" } } diff --git a/client/src/components/Editor/DraftEditor.tsx b/client/src/components/Editor/DraftEditor.tsx index 604935e..c8ef603 100644 --- a/client/src/components/Editor/DraftEditor.tsx +++ b/client/src/components/Editor/DraftEditor.tsx @@ -1,22 +1,28 @@ -import { Ctx } from '@milkdown/kit/ctx'; -import { outline } from '@milkdown/utils'; +import { CrepeEditor, outline, type CrepeEditorRef, type Ctx } from '@markdown-editor/core'; import { ScrollPanel } from 'primereact/scrollpanel'; import React, { useEffect, useId, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { getImageUrl, uploadImage } from './configs/uploadConfig'; import { getGuideDoc, getLocalModeGuideDoc } from './internalDocs/guide'; import { getVersionMismatchDoc } from './internalDocs/versionMismatch'; -import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; import { EditorRef } from './type'; import { ONLINE_MODE } from '@/constants'; -import { updateCurDoc, selectCurTabs, DocType, selectCurDoc, clearCurDoc } from '@/redux-feature/curDocSlice'; +import { + updateCurDoc, + updateHeadings, + updateScrolling, + selectCurTabs, + DocType, + selectCurDoc, + clearCurDoc, +} from '@/redux-feature/curDocSlice'; import { selectNarrowMode, selectReadonly, selectTheme, updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import Toast from '@/utils/Toast'; import { normalizeEOL } from '@/utils/utils'; -import '@milkdown/crepe/theme/common/style.css'; -import '@milkdown/crepe/theme/frame.css'; import './Editor.scss'; const getDoc = (docId: string, type: DocType) => { @@ -49,6 +55,10 @@ export interface DraftEditorProps { ref: React.RefObject; } +function tabScrollTop(tabs: { ident: string; scroll: number }[], ident: string | undefined) { + return tabs.find((tab) => tab.ident === ident)?.scroll ?? 0; +} + export const DraftEditor: React.FC = ({ ref: editorWrappedRef, type }) => { const uniqueId = useId(); @@ -154,6 +164,16 @@ export const DraftEditor: React.FC = ({ ref: editorWrappedRef, defaultValue={storedContent} isDarkMode={theme === 'dark'} readonly={readonly} + initialScrollTop={tabScrollTop(curTabs, contentIdent)} + getScrollContainer={() => document.querySelector('.editor-box .p-scrollpanel-content')} + onHeadingsChange={(headings) => dispatch(updateHeadings(headings))} + onScroll={(scrollTop) => dispatch(updateScrolling({ scrollTop }))} + onAnchorChange={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onToAnchor={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onBlurChange={(isBlurred) => dispatch(updateGlobalOpts({ keys: ['isEditorBlur'], values: [isBlurred] }))} + onToast={(message) => Toast(message)} + uploadImage={uploadImage} + getImageUrl={getImageUrl} onUpdated={onUpdated} /> diff --git a/client/src/components/Editor/Editor.scss b/client/src/components/Editor/Editor.scss index 29f449b..59173b1 100644 --- a/client/src/components/Editor/Editor.scss +++ b/client/src/components/Editor/Editor.scss @@ -1,8 +1,5 @@ -@use '../../utils/utils.scss' as *; -@use '../DocMirror/DocMirror.scss' as DocMirror; -@use './plugins/plugin-iframe/index.scss'; -@use './plugins/plugin-heading/heading.scss'; -@use './plugins/plugin-container'; +@use '../../utils/utils.scss' as *; +@use '../DocMirror/DocMirror.scss' as DocMirror; .editor-box { overflow: hidden; diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 6a63614..ecace2b 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -1,27 +1,36 @@ -import { editorViewCtx } from '@milkdown/kit/core'; -import { Ctx } from '@milkdown/kit/ctx'; -import { outline } from '@milkdown/utils'; +import { + CrepeEditor, + editorViewCtx, + outline, + searchAndHighlight, + type CrepeEditorRef, + type Ctx, +} from '@markdown-editor/core'; import { ScrollPanel } from 'primereact/scrollpanel'; import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useParams, useLocation } from 'react-router-dom'; -import { CrepeEditor, CrepeEditorRef } from './MilkdownEditor'; -import { searchAndHighlight } from './mountedAddons'; +import { getImageUrl, uploadImage } from './configs/uploadConfig'; import { EditorRef } from './type'; import { useGetSettingsQuery } from '@/redux-api/settings'; -import { updateCurDoc, selectCurDoc, selectCurTabs, clearCurDoc } from '@/redux-feature/curDocSlice'; +import { + updateCurDoc, + updateHeadings, + updateScrolling, + selectCurDoc, + selectCurTabs, + clearCurDoc, +} from '@/redux-feature/curDocSlice'; import { clearDraft, selectDraft, setDraft } from '@/redux-feature/draftsSlice'; import { getGitHubWorkspaceKey, selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; -import { selectNarrowMode, selectReadonly, selectTheme } from '@/redux-feature/globalOptsSlice'; +import { selectNarrowMode, selectReadonly, selectTheme, updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; import { useDeleteTab } from '@/utils/hooks/reduxHooks'; import { useWorkspaceDocQuery } from '@/utils/hooks/workspaceHooks'; import Toast from '@/utils/Toast'; import { getDraftKey, normalizePath, normalizeEOL } from '@/utils/utils'; -import '@milkdown/crepe/theme/common/style.css'; -import '@milkdown/crepe/theme/frame.css'; import './Editor.scss'; const SEARCH_HIGHLIGHT_DELAY_SAME_DOC = 50; @@ -199,6 +208,16 @@ export const MarkdownEditor: React.FC<{ ref: React.RefObject } defaultValue={storedContent} isDarkMode={theme === 'dark'} readonly={readonly} + initialScrollTop={curTabs.find((tab) => tab.ident === storedContentPath)?.scroll ?? 0} + getScrollContainer={() => document.querySelector('.editor-box .p-scrollpanel-content')} + onHeadingsChange={(headings) => dispatch(updateHeadings(headings))} + onScroll={(scrollTop) => dispatch(updateScrolling({ scrollTop }))} + onAnchorChange={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onToAnchor={(anchor) => dispatch(updateGlobalOpts({ keys: ['anchor'], values: [anchor] }))} + onBlurChange={(isBlurred) => dispatch(updateGlobalOpts({ keys: ['isEditorBlur'], values: [isBlurred] }))} + onToast={(message) => Toast(message)} + uploadImage={uploadImage} + getImageUrl={getImageUrl} onUpdated={onUpdated} onMounted={onMounted} /> diff --git a/client/src/components/Editor/MilkdownEditor.tsx b/client/src/components/Editor/MilkdownEditor.tsx deleted file mode 100644 index b9ccc95..0000000 --- a/client/src/components/Editor/MilkdownEditor.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { Editor, editorViewCtx, editorViewOptionsCtx, parserCtx } from '@milkdown/kit/core'; -import { Ctx } from '@milkdown/kit/ctx'; -import { listenerCtx } from '@milkdown/kit/plugin/listener'; -import { Slice } from '@milkdown/kit/prose/model'; -import { Milkdown, useEditor } from '@milkdown/react'; -import { outline } from '@milkdown/utils'; -import { FC, useEffect, useImperativeHandle, useReducer, useRef } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; - -import { getCrepe } from './crepe'; -import { removeEvents, scrollHandler, blurHandler, anchorHandler, syncMirror } from './mountedAddons'; -import { headingConfig } from './plugins/plugin-heading'; - -import { selectCurDoc, updateHeadings } from '@/redux-feature/curDocSlice'; -import { updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; -import { scrollToEditorAnchor } from '@/utils/hooks/docHooks'; -import { updateLocationHash } from '@/utils/utils'; - -export interface CrepeEditorRef { - update: (newContent: string) => void; - get: () => Editor | undefined; - /** used to trigger reser the editor */ - reRender: () => void; -} - -export interface CrepeEditorProps { - /** default as "" */ - defaultValue?: string; - /** default as false */ - isDarkMode?: boolean; - /** default as false */ - readonly?: boolean; - onMounted?: (ctx: Ctx) => void; - onUpdated?: (ctx: Ctx, markdown: string) => void; - onToAnchor?: (id: string) => void; - - ref: React.RefObject; -} - -export const CrepeEditor: FC = ({ - defaultValue = '', - isDarkMode = false, - readonly = false, - onMounted, - onUpdated, - onToAnchor, - ref: editorWrappedRef, -}) => { - const [reRenderMarker, reRender] = useReducer((x) => x + 1, 0); - - const dispatch = useDispatch(); - const { scrollTop } = useSelector(selectCurDoc); - - // This is used to avoid the closure issue - // when hook functions from props are updated but editor is not reset - const hookRefs = useRef<{ - onUpdated: CrepeEditorProps['onUpdated']; - onMounted: CrepeEditorProps['onMounted']; - onToAnchor: CrepeEditorProps['onToAnchor']; - }>({ - onUpdated: (...args) => { - onUpdated?.(...args); - }, - onMounted: (...args) => { - onMounted?.(...args); - }, - onToAnchor: (...args) => { - onToAnchor?.(...args); - }, - }); - - useEffect(() => { - hookRefs.current.onUpdated = onUpdated; - hookRefs.current.onMounted = onMounted; - hookRefs.current.onToAnchor = onToAnchor; - }, [onUpdated, onMounted, onToAnchor]); - - const { get } = useEditor( - (root) => { - const crepe = getCrepe({ - root, - defaultValue, - isDarkMode, - }); - - crepe.editor.config((ctx) => { - ctx - .get(listenerCtx) - .mounted(() => { - dispatch(updateHeadings(outline()(ctx))); - - removeEvents(); - - scrollHandler(scrollTop, dispatch); - - blurHandler(dispatch); - - // has higher priority than the scrollHandler - anchorHandler(dispatch); - - syncMirror(readonly); - - hookRefs.current.onMounted?.(ctx); - }) - .markdownUpdated((_, markdown) => { - const headings = outline()(ctx); - dispatch(updateHeadings(headings)); - - hookRefs.current.onUpdated?.(ctx, markdown); - }); - - // edit mode - ctx.set(editorViewOptionsCtx, { - editable: () => !readonly, - }); - - ctx.set(headingConfig.key, { - toAnchor: (id: string) => { - updateLocationHash(id); - scrollToEditorAnchor(id); - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [id] })); - - hookRefs.current.onToAnchor?.(id); - }, - }); - }); - - return crepe; - }, - [isDarkMode, readonly, reRenderMarker], - ); - - // for update the editor using a wrapped ref - useImperativeHandle( - editorWrappedRef, - () => ({ - get, - reRender, - update: (markdown: string) => { - const editor = get(); - if (!editor) return; - - editor.action((ctx) => { - const view = ctx.get(editorViewCtx); - const parser = ctx.get(parserCtx); - const doc = parser(markdown); - if (!doc) return; - - const state = view.state; - view.dispatch(state.tr.replace(0, state.doc.content.size, new Slice(doc.content, 0, 0))); - }); - }, - }), - [get, reRender], - ); - - return ; -}; diff --git a/client/src/components/Editor/components/TooltipInput/index.tsx b/client/src/components/Editor/components/TooltipInput/index.tsx deleted file mode 100644 index 0afa8e4..0000000 --- a/client/src/components/Editor/components/TooltipInput/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { InputText } from 'primereact/inputtext'; -import { Tooltip, TooltipProps } from 'primereact/tooltip'; -import { FC } from 'react'; - -import { Icon, IconProps } from '@/components/Icon/Icon'; - -import './index.scss'; - -export interface TooltipInputProps { - tooltipOptions: TooltipProps; - iconOptions: IconProps; - value: string; - placeholder?: string; - onChange: (value: string) => void; - onConfirm: () => void; -} - -export const TooltipInput: FC = ({ - tooltipOptions, - iconOptions, - value, - placeholder, - onChange, - onConfirm, -}) => { - return ( - - { - onChange(e.target.value); - }} - /> - { - onConfirm(); - }} - showToolTip={false} - {...iconOptions} - /> - - ); -}; diff --git a/client/src/components/Editor/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 339af63..664dd7b 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -1,9 +1,3 @@ -import { inlineImageConfig } from '@milkdown/kit/component/image-inline'; -import { uploadConfig, Uploader } from '@milkdown/kit/plugin/upload'; - -import type { Ctx } from '@milkdown/kit/ctx'; -import type { Node } from '@milkdown/kit/prose/model'; - import { SERVER_BASE_URL } from '@/constants'; export function getImageUrl(url: string) { @@ -26,51 +20,3 @@ export async function uploadImage(file: File) { const json = await res.json(); return json.data as string; } - -const uploader: Uploader = async (files, schema) => { - const images: File[] = []; - - for (let i = 0; i < files.length; i++) { - const file = files.item(i); - if (!file) { - continue; - } - - // we only handle image here. - if (!file.type.includes('image')) { - continue; - } - - images.push(file); - } - - const nodes: Node[] = await Promise.all( - images.map(async (image) => { - const src = await uploadImage(image); - const alt = image.name; - return schema.nodes.image.createAndFill({ - src, - alt, - })!; - }), - ); - - return nodes; -}; - -export const configureImageUploader = (ctx: Ctx) => { - ctx.update(uploadConfig.key, (prev) => { - return { - ...prev, - uploader, - }; - }); - - ctx.update(inlineImageConfig.key, (prev) => { - return { - ...prev, - proxyDomURL: getImageUrl, - onUpload: uploadImage, - }; - }); -}; diff --git a/client/src/components/Editor/mountedAddons.tsx b/client/src/components/Editor/mountedAddons.tsx deleted file mode 100644 index f53010e..0000000 --- a/client/src/components/Editor/mountedAddons.tsx +++ /dev/null @@ -1,321 +0,0 @@ -/* eslint-disable @typescript-eslint/no-magic-numbers */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { Decoration, DecorationSet } from '@milkdown/kit/prose/view'; -import { useDispatch } from 'react-redux'; - -import type { EditorView } from '@milkdown/kit/prose/view'; - -import { updateScrolling } from '@/redux-feature/curDocSlice'; -import { updateGlobalOpts } from '@/redux-feature/globalOptsSlice'; -import { getEditorScrollContainer, scrollToEditorAnchor } from '@/utils/hooks/docHooks'; -import { throttle } from '@/utils/utils'; - -let removers: (() => void)[] = []; -function pushRemover(r: () => void) { - removers.push(r); -} -/** - * remove all the events and unmounted doms of each addon - */ -export function removeEvents() { - removers.forEach((r) => { - r(); - }); - removers = []; -} - -/** - * handle scrolling - record the scrolling status of previous visited docs - * then record current docs scroll top globally - */ -export function scrollHandler(prevScroll: number, dispatch: ReturnType) { - const milkdownDom = getEditorScrollContainer(); - if (!milkdownDom) return; - - // get the previous scroll top - milkdownDom.scrollTop = prevScroll; - - const syncAnchor = () => { - const headings = document.querySelectorAll('.milkdown-heading'); - headings.forEach((ha) => { - const rect = ha.getBoundingClientRect(); - if (rect.top > 0 && rect.top < 150) { - // notify the outline to select the heading, but not set the anchor to location hash - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [ha.id] })); - } - }); - }; - - // wait for the outline - setTimeout(() => { - syncAnchor(); - }, 100); - - // bind the event after the first rendering caused by the above operation... - setTimeout(() => { - const eventFn = throttle(() => { - const currentScrollTop = milkdownDom.scrollTop; - - dispatch(updateScrolling({ scrollTop: currentScrollTop })); - - syncAnchor(); - }, 100); - - milkdownDom.addEventListener('scroll', eventFn); - - pushRemover(() => { - milkdownDom.removeEventListener('scroll', eventFn); - }); - }, 0); -} - -/** - * record blur status about if the mouse is on the milkdown or not - */ -export function blurHandler(dispatch: ReturnType) { - const milkdownDom = document.getElementsByClassName('milkdown')[0]; - - const enterFn = () => { - dispatch( - updateGlobalOpts({ - keys: ['isEditorBlur'], - values: [false], - }), - ); - }; - const leaveFn = () => { - dispatch( - updateGlobalOpts({ - keys: ['isEditorBlur'], - values: [true], - }), - ); - }; - - milkdownDom.addEventListener('mouseenter', enterFn); - milkdownDom.addEventListener('mouseleave', leaveFn); - - pushRemover(() => { - milkdownDom.removeEventListener('mouseenter', enterFn); - milkdownDom.removeEventListener('mouseleave', leaveFn); - }); -} - -/** - * handle anchor - */ -export function anchorHandler(dispatch: ReturnType) { - const locationHash = window.location.hash.slice(1); - let hashAnchor = ''; - if (locationHash) { - const heading = document.getElementById(decodeURI(locationHash)); - if (heading) { - hashAnchor = heading.id; - } - } - scrollToEditorAnchor(hashAnchor); - dispatch(updateGlobalOpts({ keys: ['anchor'], values: [hashAnchor || ''] })); -} - -export function keywordsHandler(keywords: string[]) { - const domSet = new Set(); - // filter the repeated keyword doms - const strongDoms = [...document.getElementsByClassName('strong')].filter( - (dom) => !domSet.has(dom.innerHTML) && domSet.add(dom.innerHTML), - ); - - if (strongDoms && strongDoms.length !== 0) { - let idx = 0; - for (const strongDom of strongDoms) { - strongDom.setAttribute('id', keywords[idx].replace(/\s/g, '-').toLowerCase()); - idx++; - } - } -} - -/** - * sync the mirror anchor when double clicking a element in Editor - * but only works for readonly mode currently... - */ -export function syncMirror(readonly: boolean) { - if (!readonly) return; - - const editorDom = document.querySelector('.milkdown .editor'); - if (!editorDom) return; - - const blockDoms = editorDom.children; - - const blockLineNum = new Array(blockDoms.length).fill(0); - - // prerecord the line number of each big block (top children of the editor) - [...blockDoms].reduce((curTotalLine, blockDom, idx) => { - const lines = (blockDom as HTMLElement).innerText.split('\n'); - - // record the start line number - blockLineNum[idx] = curTotalLine; - - if (blockDom.querySelector('img') || blockDom.querySelector('iframe')) { - // special case handling for img and iframe block - curTotalLine--; - } - - if (blockDom.classList.contains('milkdown-code-block')) { - curTotalLine -= blockDom.querySelector('.cm-content')?.children.length ?? 0; - } - - return curTotalLine + lines.length + 1; - }, 0); - - [...blockDoms].forEach((blockDom, idx) => { - const dbClickEvent = (e: Event) => { - const mirrorDom = document.querySelector('.code-mirror-container .cm-content'); - const mirrorScroller = document.querySelector('.code-mirror-container .cm-scroller'); - if (!mirrorDom || !mirrorScroller) return; - - const lineDoms = mirrorDom.children; - if (lineDoms.length === 0 || !lineDoms[idx]) return; - - const oneLineHeight = Number(getComputedStyle(lineDoms[0]).height.replace('px', '')); - - const clickDom = e.target as HTMLElement; - - let lineNum = blockLineNum[idx]; - // when it is a paragraph and it is one of children of the blockDom - // make the position more accurate - if (clickDom !== blockDom && clickDom.tagName === 'P') { - const lines = (blockDom as HTMLElement).innerText.split('\n'); - - if (clickDom) { - const lineIdx = lines.findIndex((line) => line === clickDom.innerText); - - if (lineIdx) lineNum += lineIdx; - } - } - - mirrorScroller.scroll({ - // just make some offset - top: (lineNum - 3) * oneLineHeight, - behavior: 'smooth', - }); - }; - - blockDom.addEventListener('dblclick', dbClickEvent); - pushRemover(() => { - blockDom.removeEventListener('dblclick', dbClickEvent); - }); - }); -} - -const HIGHLIGHT_DURATION_MS = 3500; - -function applyHighlightAndScroll(view: EditorView, from: number, to: number) { - // Scroll the match into view, positioned at roughly 1/3 from the top - const scrollContainer = getEditorScrollContainer(); - if (scrollContainer) { - try { - const coords = view.coordsAtPos(from); - const containerRect = scrollContainer.getBoundingClientRect(); - const scrollTop = Number(scrollContainer.scrollTop); - scrollContainer.scrollTo({ - top: scrollTop + (coords.top - containerRect.top) - containerRect.height / 3, - behavior: 'instant', - }); - } catch { - /* ignore coordinate errors */ - } - } - - // Add a visual decoration highlight that works regardless of focus state. - // Native selection depends on focus, contenteditable, and browser quirks; - // decorations are rendered as styled DOM elements and always visible. - try { - view.setProps({ - decorations(state) { - try { - return DecorationSet.create(state.doc, [Decoration.inline(from, to, { class: 'search-match-highlight' })]); - } catch { - return DecorationSet.empty; - } - }, - }); - - setTimeout(() => { - try { - if (!view.dom.isConnected) return; - view.setProps({ decorations: () => DecorationSet.empty }); - } catch { - /* view may have been destroyed */ - } - }, HIGHLIGHT_DURATION_MS); - } catch { - /* setProps failed */ - } -} - -function stripMarkdownSyntax(text: string): string { - return text - .replace(/^#{1,6}\s+/, '') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/_(.+?)_/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/^\s*[-*+]\s+/, '') - .replace(/^\s*\d+\.\s+/, '') - .replace(/^\s*>\s+/, '') - .replace(/\[(.+?)\]\(.+?\)/g, '$1') - .trim(); -} - -/** - * Search for query text in the ProseMirror document and select the best match. - * Uses lineContent (raw markdown line) for disambiguation when multiple matches exist. - * Returns true if a match was found and selected. - */ -export function searchAndHighlight(view: EditorView, query: string, lineContent: string): boolean { - const { doc } = view.state; - const lowerQuery = query.toLowerCase(); - const strippedLine = stripMarkdownSyntax(lineContent).toLowerCase(); - - const matches: { from: number; to: number; blockText: string }[] = []; - - doc.descendants((node, pos) => { - if (!node.isTextblock) return; - - const text = node.textContent; - const lowerText = text.toLowerCase(); - let searchFrom = 0; - - while (searchFrom < lowerText.length) { - const idx = lowerText.indexOf(lowerQuery, searchFrom); - if (idx === -1) break; - - matches.push({ - from: pos + 1 + idx, - to: pos + 1 + idx + query.length, - blockText: lowerText, - }); - - searchFrom = idx + 1; - } - - return false; - }); - - if (matches.length === 0) return false; - - let bestMatch = matches[0]; - if (matches.length > 1 && strippedLine) { - for (const match of matches) { - if (strippedLine.includes(match.blockText) || match.blockText.includes(strippedLine)) { - bestMatch = match; - break; - } - } - } - - applyHighlightAndScroll(view, bestMatch.from, bestMatch.to); - - return true; -} diff --git a/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx b/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx deleted file mode 100644 index 850d085..0000000 --- a/client/src/components/Editor/plugins/plugin-container/ContainerTitle.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { ListBox } from 'primereact/listbox'; -import { FC, useEffect, useId, useMemo, useRef, useState } from 'react'; - -import { ContainerType } from './types'; - -import { TooltipInput } from '@/components/Editor/components/TooltipInput'; - -export interface ContainerTitleProps { - desc?: string; - containerType: ContainerType; - contentDom: HTMLElement; - getReadonly: () => boolean; - setAttrs?: (attrs: { containerType: ContainerType; desc: string }) => void; -} - -export const ContainerTitle: FC = ({ - desc = '', - containerType, - contentDom, - getReadonly, - setAttrs, -}) => { - const uid = useId(); - - const isDetails = containerType === ContainerType.DETAILS; - const title = desc || (isDetails ? 'Details' : containerType.toUpperCase()); - - const [showOperations, setShowOperations] = useState(false); - const [showSelectorMenu, setShowSelectorMenu] = useState(false); - const [showEditDesc, setEditDesc] = useState(false); - - const [descInput, setDescInput] = useState(desc); - - const iconRef = useRef(null); - const listContainerRef = useRef(null); - - const showSelectorIcon = useMemo(() => { - return (showOperations || showSelectorMenu) && !getReadonly(); - }, [showOperations, showSelectorMenu, getReadonly]); - const showEditDescIcon = useMemo(() => { - return (showOperations || showEditDesc) && !getReadonly(); - }, [showOperations, showEditDesc, getReadonly]); - - const onToggle = () => { - if (containerType !== ContainerType.DETAILS) { - return; - } - - iconRef.current?.classList.toggle('pi-angle-down'); - iconRef.current?.classList.toggle('pi-angle-right'); - contentDom.classList.toggle('container-content-hidden'); - }; - - const showSelectContainerType = (e: React.MouseEvent) => { - e.stopPropagation(); - setShowSelectorMenu(!showSelectorMenu); - }; - - const showDescEditor = (e: React.MouseEvent) => { - e.stopPropagation(); - setEditDesc(!showEditDesc); - }; - - const selectContainerType = (value: ContainerType) => { - setAttrs?.({ containerType: value, desc: descInput }); - }; - - const clickHandler = (e: MouseEvent) => { - const target = e.target as HTMLElement; - - if (listContainerRef.current?.contains?.(target)) return; - - setShowSelectorMenu(false); - }; - - useEffect(() => { - window.addEventListener('click', clickHandler); - return () => { - window.removeEventListener('click', clickHandler); - }; - }, []); - - const titleContent = ( -
    - {title} - {showSelectorIcon && ( -
    - - {showSelectorMenu && ( -
    { - e.stopPropagation(); - }} - > - { - selectContainerType(e.value); - }} - options={Object.keys(ContainerType)} - /> -
    - )} -
    - {showEditDescIcon && ( - - { - setDescInput(value); - }} - onConfirm={() => { - setAttrs?.({ containerType: containerType, desc: descInput }); - }} - /> - - )} -
    - )} -
    - ); - - return ( - { - setShowOperations(true); - }} - onMouseLeave={() => { - setShowOperations(false); - }} - > - {isDetails && } - {titleContent} - - ); -}; diff --git a/client/src/components/Editor/plugins/plugin-container/index.scss b/client/src/components/Editor/plugins/plugin-container/index.scss deleted file mode 100644 index a2a189e..0000000 --- a/client/src/components/Editor/plugins/plugin-container/index.scss +++ /dev/null @@ -1,78 +0,0 @@ -@use '@/utils/utils.scss' as *; -@use '@/components/Editor/components/TooltipInput'; - -$tipBackgroundColor: #646cff24; -$infoBackgroundColor: #8e96aa24; -$warningBackgroundColor: #eab30824; -$dangerBackgroundColor: #f43f5e24; -$detailsBackgroundColor: #8e96aa24; - -.milkdown-container-block { - margin: 16px 0; - padding: 16px 16px 8px; - border-radius: 8px; - &-title { - font-weight: 600; - line-height: 24px; - @include flex-center(); - justify-content: flex-start; - .pi { - margin-right: 3px; - cursor: pointer; - } - .title-container { - position: relative; - @include flex-center(); - justify-content: flex-start; - .operations { - margin-left: 5px; - @include flex-center(); - i { - font-size: 12px; - margin-right: 5px; - } - } - .list-container { - position: absolute; - z-index: 1000; - } - .p-listbox-list-wrapper, - .p-listbox-list, - .p-listbox-item-group { - border-radius: 5px; - padding: 0.5rem 0; - background-color: var(--crepe-color-surface); - } - .p-listbox { - // border: 1px solid $shadowColor; - box-shadow: var(--crepe-shadow-1); - } - .p-listbox-item { - padding: 0.5rem; - font-weight: bold; - } - } - } - .container-content-hidden { - display: none; - } - &[data-container-type="tip"] { - background-color: $tipBackgroundColor; - } - &[data-container-type="warning"] { - background-color: $warningBackgroundColor; - } - &[data-container-type="info"] { - background-color: $infoBackgroundColor; - } - &[data-container-type="danger"] { - background-color: $dangerBackgroundColor; - } - &[data-container-type="details"] { - background-color: $detailsBackgroundColor; - .milkdown-container-block-title { - margin-bottom: 8px; - cursor: pointer; - } - } -} \ No newline at end of file diff --git a/client/src/components/Editor/plugins/plugin-container/index.ts b/client/src/components/Editor/plugins/plugin-container/index.ts deleted file mode 100644 index 6d300e9..0000000 --- a/client/src/components/Editor/plugins/plugin-container/index.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* eslint-disable @typescript-eslint/naming-convention */ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { wrapIn } from '@milkdown/kit/prose/commands'; -import { $nodeSchema, $inputRule, $command, $view } from '@milkdown/utils'; -import { wrappingInputRule } from 'prosemirror-inputrules'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { ContainerTitle } from './ContainerTitle'; -import { ContainerType, ContainerNodeAttrs } from './types'; - -export * from './types'; - -export const containerSchema = $nodeSchema('container', () => { - return { - content: 'block+', - group: 'block', - defining: true, - attrs: { - containerType: { - default: ContainerType.TIP, - validate: (value) => Object.values(ContainerType).includes(value as ContainerType), - }, - desc: { - default: '', - validate: 'string', - }, - }, - parseDOM: [ - { - tag: 'div', - getAttrs: (dom) => ({ - containerType: dom.getAttribute('data-container-type') ?? ContainerType.TIP, - desc: dom.getAttribute('data-description') ?? '', - }), - }, - ], - parseMarkdown: { - match: (node) => { - return node.type === 'containerDirective' && Object.values(ContainerType).includes(node.name as ContainerType); - }, - runner: (state, node, type) => { - state - .openNode(type, { - containerType: node.name, - desc: (node.attributes as ContainerNodeAttrs).desc, - }) - .next(node.children) - .closeNode(); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'container', - runner: (state, node) => { - const attributes: Record = {}; - if (node.attrs.desc) { - attributes.desc = node.attrs.desc; - } - state - .openNode('containerDirective', undefined, { - name: node.attrs.containerType, - attributes, - }) - .next(node.content) - .closeNode(); - }, - }, - }; -}); - -const containerView = $view(containerSchema.node, () => { - return (initialNode, view, getPos) => { - const { containerType, desc } = initialNode.attrs; - const isDetails = containerType === ContainerType.DETAILS; - - const contentDom = document.createElement('p'); - contentDom.classList.add('milkdown-container-block-content'); - if (isDetails) { - contentDom.classList.add('container-content-hidden'); - } - - const dom = document.createElement('div'); - dom.classList.add('milkdown-container-block'); - dom.setAttribute('data-container-type', containerType); - dom.setAttribute('data-description', desc); - - const titleContainerDom = document.createElement('div'); - dom.appendChild(titleContainerDom); - dom.appendChild(contentDom); - - const titleRoot = createRoot(titleContainerDom); - - const setAttrs = (attrs: { containerType: ContainerType; desc: string }) => { - const pos = getPos(); - if (pos == null) return; - view.dispatch(view.state.tr.setNodeAttribute(pos, 'containerType', attrs.containerType.toLowerCase())); - const inValidDesc = Object.keys(ContainerType).includes(attrs.desc.trim().toUpperCase()); - view.dispatch(view.state.tr.setNodeAttribute(pos, 'desc', inValidDesc ? '' : attrs.desc)); - }; - - titleRoot.render( - createElement(ContainerTitle, { - desc, - containerType, - contentDom, - getReadonly: () => !view.editable, - setAttrs, - }), - ); - - return { - dom, - contentDOM: contentDom, - update: (updatedNode) => { - if ( - updatedNode.type !== initialNode.type || - updatedNode.attrs.desc !== desc || - updatedNode.attrs.containerType !== containerType - ) - return false; - - titleRoot.render( - createElement(ContainerTitle, { - desc: updatedNode.attrs.desc, - containerType: updatedNode.attrs.containerType, - contentDom, - getReadonly: () => !view.editable, - setAttrs, - }), - ); - return true; - }, - destroy() { - titleRoot.unmount(); - }, - ignoreMutation(mutation) { - if (mutation.type === 'selection' && mutation.target.nodeName === 'SPAN') return false; - if (mutation.type === 'selection' && (mutation.target as HTMLElement).classList.contains('title-container')) - return false; - return true; - }, - }; - }; -}); - -const containerInputRule = $inputRule((ctx) => { - return wrappingInputRule( - /^:::(tip|warning|info|danger|details)\s+([\S\s]+)\s?:::$/, - containerSchema.type(ctx), - (match) => ({ - containerType: match[1] || ContainerType.TIP, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - desc: match[2] || '', - }), - ); -}); - -export const wrapInContainerCommand = $command('WrapInContainer', (ctx) => () => wrapIn(containerSchema.type(ctx))); - -export const containerPlugin: MilkdownPlugin[] = [containerSchema, containerView, containerInputRule].flat(); diff --git a/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx b/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx deleted file mode 100644 index 57369f2..0000000 --- a/client/src/components/Editor/plugins/plugin-heading/Anchor.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { FC } from 'react'; - -import { updateLocationHash } from '@/utils/utils'; - -export interface AnchorProps { - id: string; - text: string; - toAnchor?: (id: string) => void; -} - -export const Anchor: FC = ({ id, text, toAnchor }) => { - const onClickAnchor = (e: React.MouseEvent) => { - e.stopPropagation(); - - if (toAnchor) { - toAnchor(id); - return; - } - - updateLocationHash(id); - }; - - return ( - - # - - ); -}; diff --git a/client/src/components/Editor/plugins/plugin-heading/index.ts b/client/src/components/Editor/plugins/plugin-heading/index.ts deleted file mode 100644 index f40b84a..0000000 --- a/client/src/components/Editor/plugins/plugin-heading/index.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { headingAttr, headingSchema } from '@milkdown/kit/preset/commonmark'; -import { $ctx, $view } from '@milkdown/utils'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; - -import { Anchor } from './Anchor'; - -import { headerToId } from '@/utils/utils'; - -export interface HeadingConfig { - toAnchor?: (id: string) => void; -} - -export const headingDefaultConfig: HeadingConfig = {}; - -export const headingConfig = $ctx(headingDefaultConfig, 'headingConfig'); - -export const headingView = $view(headingSchema.node, (ctx) => { - return (initialNode) => { - const dom = document.createElement(`h${initialNode.attrs.level as string}`); - const attrs = { - ...ctx.get(headingAttr.key)(initialNode), - id: initialNode.attrs.id || headerToId(initialNode.textContent), - }; - Object.entries(attrs).forEach(([key, value]) => { - dom.setAttribute(key, value as string); - }); - dom.classList.add('milkdown-heading'); - - const contentDom = document.createElement('span'); - contentDom.textContent = initialNode.textContent; - dom.appendChild(contentDom); - - const anchorContainer = document.createElement('div'); - anchorContainer.classList.add('heading-anchor-container'); - dom.appendChild(anchorContainer); - const root = createRoot(anchorContainer); - - const { toAnchor } = ctx.get(headingConfig.key); - - root.render( - createElement(Anchor, { - id: initialNode.attrs.id as string, - text: initialNode.textContent, - toAnchor, - }), - ); - - return { - dom, - contentDOM: contentDom, - update: (updatedNode) => { - if (updatedNode.type !== initialNode.type) return false; - - dom.setAttribute('id', updatedNode.attrs.id); - - root.render( - createElement(Anchor, { - id: updatedNode.attrs.id as string, - text: updatedNode.textContent, - toAnchor, - }), - ); - return true; - }, - destroy() { - root.unmount(); - }, - ignoreMutation(mutation) { - if ( - mutation.type === 'childList' && - (mutation.target as HTMLElement).classList.contains('heading-anchor-container') - ) - return true; - - // avoid typing blur - if (mutation.type === 'attributes' && (mutation.target as HTMLElement).classList.contains('heading-anchor')) - return true; - - return false; - }, - }; - }; -}); - -export const headingPlugin: MilkdownPlugin[] = [headingView, headingConfig].flat(); diff --git a/client/src/components/Editor/plugins/plugin-highlight/commnads.ts b/client/src/components/Editor/plugins/plugin-highlight/commnads.ts deleted file mode 100644 index 83d6e48..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/commnads.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Mark } from '@milkdown/kit/prose/model'; -import { $command } from '@milkdown/utils'; - -import { highlightSchema } from './index'; -import { colorPickTooltipAPI } from './slices'; - -export const toggleColorPickerCommand = $command('ToggleColorPicker', (ctx) => { - return () => (state) => { - const { - doc, - selection: { from, to }, - } = state; - const markType = highlightSchema.type(ctx); - const hasColorPicker = doc.rangeHasMark(from, to, markType); - if (hasColorPicker) { - ctx.get(colorPickTooltipAPI.key).removeColorPicker(from, to); - return true; - } - - let mark: Mark | null = null; - doc.nodesBetween(from, to, (node) => { - if (!markType.isInSet(node.marks)) return; - mark = node.marks.find((m) => m.type === markType) ?? null; - }); - - ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); - return true; - }; -}); - -export const showColorPickerCommand = $command('ToggleColorPicker', (ctx) => { - return () => (state) => { - const { - doc, - selection: { from, to }, - } = state; - - const markType = highlightSchema.type(ctx); - let mark: Mark | null = null; - doc.nodesBetween(from, to, (node) => { - if (!markType.isInSet(node.marks)) return; - mark = node.marks.find((m) => m.type === markType) ?? null; - }); - - ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); - - return true; - }; -}); diff --git a/client/src/components/Editor/plugins/plugin-highlight/index.ts b/client/src/components/Editor/plugins/plugin-highlight/index.ts deleted file mode 100644 index e7d847a..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { markRule } from '@milkdown/kit/prose'; -import { $inputRule, $markSchema, $remark } from '@milkdown/kit/utils'; -import { Mark } from 'mdast'; - -import { showColorPickerCommand, toggleColorPickerCommand } from './commnads'; -import { remarkMarkColor } from './remark-mark-color'; -import { colorPickTooltipAPI, colorOptionsConfig } from './slices'; -import { colorPickTooltip } from './tooltip'; - -export const DEFAULT_COLOR = '#ffff00'; - -export const highlightSchema = $markSchema('mark', () => { - return { - attrs: { - color: { - default: DEFAULT_COLOR, - validate: 'string', - }, - }, - parseDOM: [ - { - tag: 'mark', - getAttrs: (node: HTMLElement) => { - return { - color: node.style.backgroundColor, - }; - }, - }, - ], - toDOM: (mark) => ['mark', { style: `background-color: ${mark.attrs.color as string}` }], - parseMarkdown: { - match: (node) => node.type === 'mark', - runner: (state, node, markType) => { - const color = (node as Mark).data?.color; - state.openMark(markType, { color }); - state.next(node.children); - state.closeMark(markType); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'mark', - runner: (state, mark) => { - let color = mark.attrs.color; - if (color?.toLowerCase() === DEFAULT_COLOR.toLowerCase()) { - color = undefined; - } - state.withMark(mark, 'mark', undefined, { - data: { color }, - }); - }, - }, - }; -}); - -export const highlightInputRule = $inputRule((ctx) => { - return markRule(/(?:==)(?:\{([^}]+)\})?([^=]+)(?:==)$/, highlightSchema.type(ctx), { - getAttr: (match) => { - const color = match[1]; - return { - color: color || null, - }; - }, - }); -}); - -const markRemark = $remark('highlightColor', () => remarkMarkColor); - -export const highlightMarkerPlugin: MilkdownPlugin[] = [ - highlightSchema, - highlightInputRule, - markRemark, - colorPickTooltip, - colorPickTooltipAPI, - colorOptionsConfig, - toggleColorPickerCommand, - showColorPickerCommand, -].flat(); - -export * from './configration'; -export * from './slices'; -export * from './commnads'; diff --git a/client/src/components/Editor/plugins/plugin-highlight/slices.ts b/client/src/components/Editor/plugins/plugin-highlight/slices.ts deleted file mode 100644 index 407b0c2..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/slices.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Mark } from '@milkdown/kit/prose/model'; -import { $ctx } from '@milkdown/utils'; - -import { ColorOptions } from './view/ColorPicker'; - -export interface ColorPickTooltipAPI { - addColorPicker: (mark: Mark | null, from: number, to: number) => void; - removeColorPicker: (from: number, to: number) => void; -} - -const defaultAPI: ColorPickTooltipAPI = { - addColorPicker: () => { - //... - }, - removeColorPicker: () => { - //... - }, -}; - -export const colorPickTooltipAPI = $ctx({ ...defaultAPI }, 'colorPickTooltipAPICtx'); - -export const defaultColorOptions: ColorOptions[] = [ - { - label: 'Background', - code: 'BG', - items: [ - { - label: 'Default', - value: 'default', - }, - { - label: 'Red', - value: '#fed5d5', - }, - { - label: 'Orange', - value: '#fedfbb', - }, - { - label: 'Yellow', - value: '#fef3a1', - }, - { - label: 'Green', - value: '#e1fab1', - }, - { - label: 'Teal', - value: '#adf8e9', - }, - { - label: 'Blue', - value: '#cce2fe', - }, - { - label: 'Purple', - value: '#edddff', - }, - { - label: 'Grey', - value: '#eaecef', - }, - ], - }, -]; - -export const colorOptionsConfig = $ctx(defaultColorOptions, 'colorOptionsCtx'); diff --git a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss b/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss deleted file mode 100644 index b4c6186..0000000 --- a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.scss +++ /dev/null @@ -1,54 +0,0 @@ -@use '@/utils/utils.scss' as *; - -.milkdown-color-picker { - position: absolute; - z-index: 10; - box-shadow: var(--crepe-shadow-1); - background-color: var(--crepe-color-surface); - border-radius: $borderRadius; - &[data-show='false'] { - display: none; - } - - .color-picker-container { - width: 150px; - padding: 10px; - .group-title { - font-size: 12px; - color: $shallowTextColor; - text-align: start; - font-weight: normal; - margin-bottom: 0.5rem - } - .color-item { - @include flex-center(); - justify-content: flex-start; - padding: 5px; - border-radius: $borderRadius; - .demo { - border: 1px solid $shadowColor; - border-radius: $borderRadius; - padding: 3px; - width: 16px; - height: 16px; - font-size: 12px; - font-weight: normal; - margin-right: 1rem; - @include flex-center(); - } - .desc { - font-size: 12px; - font-weight: normal; - } - } - } - - .color-picker-listbox { - border: none; - .p-listbox-list-wrapper, - .p-listbox-list, - .p-listbox-item-group { - background-color: var(--crepe-color-surface); - } - } -} diff --git a/client/src/components/Editor/plugins/plugin-iframe/index.scss b/client/src/components/Editor/plugins/plugin-iframe/index.scss deleted file mode 100644 index d32f41f..0000000 --- a/client/src/components/Editor/plugins/plugin-iframe/index.scss +++ /dev/null @@ -1,70 +0,0 @@ -@use '@/utils/utils.scss' as *; -@use 'sass:color'; -@use '@/components/Editor/components/TooltipInput'; - -.milkdown .iframe-plugin-container { - border-radius: $borderRadius; - margin: 16px 0; - display: flex; - flex-direction: column; - align-items: start; - justify-content: center; - &.ProseMirror-selectednode { - background-color: $backgroundColor; - } - .iframe-loader { - @include flex-center(); - width: 100%; - height: 100%; - } - .iframe-plugin { - border-radius: $borderRadius; - width: fit-content; - width: 100%; - aspect-ratio: var(--aspect-video, 16 / 9); - outline: none; - border: none; - &.loading { - display: none; - } - } - .iframe-plugin-link { - cursor: pointer; - color: $contentTextColor; - font-weight: light; - height: 32px; - padding: 4px 8px; - border-top-left-radius: .25rem; - border-top-right-radius: .25rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - font-size: 16px; - text-decoration: none; - background-color: $buttonBgcColor; - &:hover { - background-color: $buttonHoverBgcColor; - } - span { - font-size: 14px; - } - } -} - -.iframe-link-tooltip { - .p-tooltip-arrow { - display: none; - } - .p-tooltip-text { - background-color: $backgroundColor; - padding: 10px; - @include flex-center(); - input { - height: 24px; - margin-right: 1rem; - background-color: $backgroundColor; - color: $contentTextColor; - } - } -} \ No newline at end of file diff --git a/client/src/components/Editor/plugins/plugin-iframe/index.ts b/client/src/components/Editor/plugins/plugin-iframe/index.ts deleted file mode 100644 index 4e760ed..0000000 --- a/client/src/components/Editor/plugins/plugin-iframe/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { MilkdownPlugin } from '@milkdown/kit/ctx'; -import { $remark, $nodeSchema, $inputRule, $view } from '@milkdown/kit/utils'; -import { InputRule } from 'prosemirror-inputrules'; -import { createElement } from 'react'; -import { createRoot } from 'react-dom/client'; -import directive from 'remark-directive'; - -import { IframeView } from './IframeView'; - -const remarkPluginId = 'Iframe'; -const remarkDirective = $remark(remarkPluginId, () => directive); - -export const iframeBlockSchema = $nodeSchema('iframe', () => ({ - group: 'block', // Block-level node - atom: true, // Cannot be split - isolating: true, // Cannot be merged with adjacent nodes - marks: '', // No marks allowed - attrs: { - src: { default: 'https://example.com' }, // URL attribute - }, - defining: true, - code: true, - parseDOM: [ - { - tag: 'iframe', - getAttrs: (dom) => ({ - src: dom.getAttribute('src'), - }), - }, - ], - parseMarkdown: { - match: (node) => node.type === 'leafDirective' && node.name === 'iframe', - runner: (state, node, type) => { - state.addNode(type, { src: (node.attributes as { src: string }).src }); - }, - }, - toMarkdown: { - match: (node) => node.type.name === 'iframe', - runner: (state, node) => { - state.addNode('leafDirective', undefined, undefined, { - name: 'iframe', - attributes: { src: node.attrs.src }, - }); - }, - }, -})); - -const iframeView = $view(iframeBlockSchema.node, () => { - return (initialNode, view, getPos) => { - const dom = document.createElement('div'); - dom.style.borderRadius = '5px'; - const root = createRoot(dom); - - const setAttrs = ({ src }: { src: string }) => { - if (!view.editable) return; - const pos = getPos(); - if (pos == null) return; - view.dispatch(view.state.tr.setNodeAttribute(pos, 'src', src)); - }; - - root.render(createElement(IframeView, { readonly: !view.editable, src: initialNode.attrs.src, setAttrs })); - - return { - dom, - update: (updatedNode) => { - if (updatedNode.type !== initialNode.type) return false; - - root.render(createElement(IframeView, { readonly: !view.editable, src: updatedNode.attrs.src, setAttrs })); - return true; - }, - destroy() { - root.unmount(); - }, - }; - }; -}); - -const iframeInputRule = $inputRule( - (ctx) => - new InputRule(/:iframe\{src="(?[^"]+)?"?\}/, (state, match, start, end) => { - const [okay, src = ''] = match; - const { tr } = state; - if (okay) { - tr.replaceWith(start - 1, end, iframeBlockSchema.type(ctx).create({ src })); - } - return tr; - }), -); - -export const iframePlugin: MilkdownPlugin[] = [remarkDirective, iframeBlockSchema, iframeView, iframeInputRule].flat(); diff --git a/client/src/components/EditorContainer/EditorContainer.tsx b/client/src/components/EditorContainer/EditorContainer.tsx index c29691e..1e33ffe 100644 --- a/client/src/components/EditorContainer/EditorContainer.tsx +++ b/client/src/components/EditorContainer/EditorContainer.tsx @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { MilkdownProvider } from '@milkdown/react'; import Split from '@uiw/react-split'; import { useMemo, useRef, FC } from 'react'; import { useSelector } from 'react-redux'; @@ -76,30 +75,9 @@ export const EditorContainer: FC = () => { >
    - - - - } - /> - - - - } - /> - - - - } - /> + } /> + } /> + } /> } /> } /> diff --git a/package.json b/package.json index c8cbc50..25ecbb7 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "build:macos": "pnpm --filter crates build-macos", "build:windows": "pnpm --filter crates build-windows", "build:all": "pnpm --filter crates build", - "type-check": "pnpm --filter @markdown-editor/github-workspace type-check && pnpm --filter client type-check && pnpm --filter github-auth-worker type-check", + "type-check": "pnpm --filter @markdown-editor/core type-check && pnpm --filter @markdown-editor/github-workspace type-check && pnpm --filter client type-check && pnpm --filter github-auth-worker type-check", "type-check:full": "pnpm --filter github-auth-worker cf-typegen && pnpm type-check", "lint": "eslint client/**/*.{ts,tsx} github-auth-worker/src/**/*.ts --fix", "release": "tsx scripts/bump-version.ts", diff --git a/packages/editor-core/README.md b/packages/editor-core/README.md new file mode 100644 index 0000000..fc353dd --- /dev/null +++ b/packages/editor-core/README.md @@ -0,0 +1,66 @@ +# @markdown-editor/core + +Reusable Milkdown/Crepe editor with its provider, configuration, custom plugins, components, and styles included. + +## Usage + +```tsx +import { CrepeEditor, type CrepeEditorRef } from '@markdown-editor/core'; + +const editorRef = useRef(null); + + console.log(markdown)} + uploadImage={async (file) => uploadToYourBackend(file)} +/>; +``` + +`CrepeEditor` includes its own `MilkdownProvider` and can be rendered directly. + +## Props + +| Prop | Type | Default / purpose | +| --- | --- | --- | +| `defaultValue` | `string` | `''`; initial Markdown | +| `isDarkMode` | `boolean` | `false`; dark CodeMirror and Mermaid themes | +| `readonly` | `boolean` | `false`; disables editing | +| `initialScrollTop` | `number` | `0`; restored editor scroll position | +| `getScrollContainer` | `() => HTMLElement \| null` | Provides the editor scroll container | +| `getMirrorContainer` | `() => HTMLElement \| null` | Provides the source/mirror container for synchronization | +| `uploadImage` | `(file: File) => Promise` | Uploads an image and returns its URL | +| `getImageUrl` | `(url: string) => string` | Resolves stored image URLs for display | +| `onMounted` | `(ctx: Ctx) => void` | Called after the editor mounts | +| `onUpdated` | `(ctx: Ctx, markdown: string) => void` | Called when Markdown changes | +| `onHeadingsChange` | `(headings: EditorHeading[]) => void` | Reports the current outline | +| `onScroll` | `(scrollTop: number) => void` | Reports editor scrolling | +| `onAnchorChange` | `(anchor: string) => void` | Reports the active heading anchor | +| `onToAnchor` | `(id: string) => void` | Called when a heading permalink is selected | +| `onBlurChange` | `(isBlurred: boolean) => void` | Reports whether the pointer is outside the editor | +| `onToast` | `(message: string) => void` | Handles link and code-copy notifications | + +All props are optional. Image upload throws until `uploadImage` is supplied. + +### Ref API + +| Method | Purpose | +| --- | --- | +| `update(markdown)` | Replaces the editor content | +| `get()` | Returns the Milkdown `Editor`, if mounted | +| `reRender()` | Recreates the editor with the current props | + +## Included features + +- Crepe block menu, formatting toolbar, link tooltip, image blocks, and CodeMirror blocks. +- Mermaid code-block previews with light/dark themes. +- Image upload, paste/drop support, inline images, and custom URL resolution. +- Heading permalinks, outline reporting, active-anchor tracking, and scroll restoration. +- Colored highlights using `==text==` or `=={#color}text==`, with a color picker. +- Containers: `tip`, `info`, `warning`, `danger`, and collapsible `details`. +- Editable iframe blocks using `:iframe{src="https://example.com"}`. +- Readonly mode, editor/mirror synchronization, blur tracking, and search-result highlighting. + +The package entry imports the Milkdown theme and feature styles. Hosts provide `react`, `react-dom`, and PrimeReact theme/icon styles. For explicit style ordering, import `@markdown-editor/core/styles.scss`. + +Lower-level editor factories, plugins, components, configs, and helpers are also exported from the package entry point and feature subpaths. diff --git a/packages/editor-core/package.json b/packages/editor-core/package.json new file mode 100644 index 0000000..3d8dfd1 --- /dev/null +++ b/packages/editor-core/package.json @@ -0,0 +1,44 @@ +{ + "name": "@markdown-editor/core", + "version": "0.1.0", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./styles.scss": "./src/styles/index.scss", + "./components": "./src/components/index.ts", + "./configs": "./src/configs/index.ts", + "./plugins": "./src/plugins/index.ts", + "./upload": "./src/configs/uploadConfig.ts" + }, + "scripts": { + "type-check": "tsc --noEmit" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "dependencies": { + "@milkdown/crepe": "7.21.2", + "@milkdown/kit": "7.21.2", + "@milkdown/react": "7.21.2", + "@milkdown/utils": "7.21.2", + "@uiw/codemirror-theme-github": "^4.24.2", + "mdast-util-from-markdown": "^2.0.2", + "mdast-util-to-markdown": "^2.1.2", + "mermaid": "^11.11.0", + "micromark-extension-highlight-mark": "^1.2.0", + "micromark-util-types": "^2.0.2", + "primereact": "^10.9.6", + "prosemirror-inputrules": "1.5.1", + "remark-directive": "^4.0.0", + "remark-highlight-mark": "^1.4.0", + "unist-util-visit": "^5.0.0", + "unified": "^11.0.5" + }, + "devDependencies": { + "@types/react": "^19.1.6", + "@types/react-dom": "^19.1.5", + "@types/mdast": "^4.0.4", + "typescript": "npm:@typescript/typescript6@^6.0.2" + } +} diff --git a/packages/editor-core/src/MilkdownEditor.tsx b/packages/editor-core/src/MilkdownEditor.tsx new file mode 100644 index 0000000..997e7ba --- /dev/null +++ b/packages/editor-core/src/MilkdownEditor.tsx @@ -0,0 +1,147 @@ +import { Editor, editorViewCtx, editorViewOptionsCtx, parserCtx } from '@milkdown/kit/core'; +import { listenerCtx } from '@milkdown/kit/plugin/listener'; +import { Slice } from '@milkdown/kit/prose/model'; +import { Milkdown, MilkdownProvider, useEditor } from '@milkdown/react'; +import { outline } from '@milkdown/utils'; +import { forwardRef, useEffect, useImperativeHandle, useReducer, useRef } from 'react'; + +import { getCrepe, type CrepeOptions } from './crepe'; +import { mountEditorAddons, removeEvents } from './mountedAddons'; +import { headingConfig } from './plugins/plugin-heading'; + +import type { Ctx } from '@milkdown/kit/ctx'; + +export interface EditorHeading { + text: string; + level: number; + id: string; +} + +export interface CrepeEditorRef { + update: (newContent: string) => void; + get: () => Editor | undefined; + reRender: () => void; +} + +export interface CrepeEditorProps extends Pick { + defaultValue?: string; + isDarkMode?: boolean; + readonly?: boolean; + initialScrollTop?: number; + getScrollContainer?: () => HTMLElement | null; + getMirrorContainer?: () => HTMLElement | null; + onMounted?: (ctx: Ctx) => void; + onUpdated?: (ctx: Ctx, markdown: string) => void; + onToAnchor?: (id: string) => void; + onHeadingsChange?: (headings: EditorHeading[]) => void; + onScroll?: (scrollTop: number) => void; + onAnchorChange?: (anchor: string) => void; + onBlurChange?: (isBlurred: boolean) => void; +} + +const CrepeEditorContent = forwardRef( + ( + { + defaultValue = '', + isDarkMode = false, + readonly = false, + initialScrollTop = 0, + getScrollContainer, + getMirrorContainer, + onMounted, + onUpdated, + onToAnchor, + onHeadingsChange, + onScroll, + onAnchorChange, + onBlurChange, + onToast, + uploadImage, + getImageUrl, + }, + editorWrappedRef, + ) => { + const [reRenderMarker, reRender] = useReducer((value) => value + 1, 0); + const callbacks = useRef({ onMounted, onUpdated, onToAnchor, onHeadingsChange }); + useEffect(() => { + callbacks.current = { onMounted, onUpdated, onToAnchor, onHeadingsChange }; + }, [onMounted, onUpdated, onToAnchor, onHeadingsChange]); + + const { get } = useEditor( + (root) => { + const crepe = getCrepe({ root, defaultValue, isDarkMode, onToast, uploadImage, getImageUrl }); + crepe.editor.config((ctx) => { + ctx + .get(listenerCtx) + .mounted(() => { + const headings = outline()(ctx); + callbacks.current.onHeadingsChange?.(headings); + mountEditorAddons({ + readonly, + initialScrollTop, + getScrollContainer, + getMirrorContainer, + onScroll, + onAnchorChange, + onBlurChange, + }); + callbacks.current.onMounted?.(ctx); + }) + .markdownUpdated((_, markdown) => { + const headings = outline()(ctx); + callbacks.current.onHeadingsChange?.(headings); + callbacks.current.onUpdated?.(ctx, markdown); + }); + ctx.set(editorViewOptionsCtx, { editable: () => !readonly }); + ctx.set(headingConfig.key, { + toAnchor: (id: string) => { + window.history.replaceState(null, '', `#${encodeURIComponent(id)}`); + getScrollContainer?.()?.scrollTo({ top: document.getElementById(id)?.offsetTop ?? 0, behavior: 'auto' }); + callbacks.current.onToAnchor?.(id); + }, + }); + }); + return crepe; + }, + [isDarkMode, readonly, reRenderMarker], + ); + + useImperativeHandle( + editorWrappedRef, + () => ({ + get, + reRender, + update: (markdown: string) => { + const editor = get(); + if (!editor) return; + editor.action((ctx) => { + const view = ctx.get(editorViewCtx); + const doc = ctx.get(parserCtx)(markdown); + if (!doc) return; + view.dispatch(view.state.tr.replace(0, view.state.doc.content.size, new Slice(doc.content, 0, 0))); + }); + }, + }), + [get, reRender], + ); + + useEffect( + () => () => { + removeEvents(); + }, + [], + ); + return ; + }, +); + +CrepeEditorContent.displayName = 'CrepeEditorContent'; + +/** Complete editor component, including the Milkdown React provider. */ +export const CrepeEditor = forwardRef((props, ref) => ( + + + +)); + +CrepeEditor.displayName = 'CrepeEditor'; diff --git a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.tsx b/packages/editor-core/src/components/ColorPicker.tsx similarity index 57% rename from client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.tsx rename to packages/editor-core/src/components/ColorPicker.tsx index 1d59ca3..fab24cb 100644 --- a/client/src/components/Editor/plugins/plugin-highlight/view/ColorPicker.tsx +++ b/packages/editor-core/src/components/ColorPicker.tsx @@ -1,5 +1,5 @@ -import { ListBox, ListBoxChangeEvent } from 'primereact/listbox'; -import { FC, useEffect, useState } from 'react'; +import { ListBox, type ListBoxChangeEvent } from 'primereact/listbox'; +import { type FC, useEffect, useState } from 'react'; export interface ColorOptions { label: string; @@ -15,26 +15,20 @@ export interface ColorPickerProps { } export const ColorPicker: FC = ({ onColorChange, initialColor, colorOptions }) => { - const [selectedColor, setSelectedColor] = useState(initialColor); - + const [selectedColor, setSelectedColor] = useState(initialColor); useEffect(() => { setSelectedColor(initialColor); }, [initialColor]); - const groupTemplate = (option: { label: string }) => { - return
    {option.label}
    ; - }; - - const itemTemplate = (option: { label: string; value: string }) => { - return ( -
    -
    - A -
    -
    {option.label}
    + const groupTemplate = (option: { label: string }) =>
    {option.label}
    ; + const itemTemplate = (option: { label: string; value: string }) => ( +
    +
    + A
    - ); - }; +
    {option.label}
    +
    + ); return (
    @@ -47,9 +41,9 @@ export const ColorPicker: FC = ({ onColorChange, initialColor, optionGroupTemplate={groupTemplate} itemTemplate={itemTemplate} className="color-picker-listbox" - onChange={(e: ListBoxChangeEvent) => { - setSelectedColor(e.value); - onColorChange(e.value); + onChange={(event: ListBoxChangeEvent) => { + setSelectedColor(event.value); + onColorChange(event.value); }} />
    diff --git a/packages/editor-core/src/components/Icon.scss b/packages/editor-core/src/components/Icon.scss new file mode 100644 index 0000000..0aa1a16 --- /dev/null +++ b/packages/editor-core/src/components/Icon.scss @@ -0,0 +1,18 @@ +@use '../styles/variables' as *; + +.icon-wrapper { + @include flex-center(); + .icon { + cursor: pointer; + @include flex-center(); + &.disabled { cursor: not-allowed; opacity: .5; } + } + .icon-com { + display: inline-block; + svg { width: 100%; height: 100%; } + } +} + +.icon-tool-tip { + .p-tooltip-text { padding: 5px; font-size: 12px; } +} diff --git a/packages/editor-core/src/components/Icon.tsx b/packages/editor-core/src/components/Icon.tsx new file mode 100644 index 0000000..0de95d6 --- /dev/null +++ b/packages/editor-core/src/components/Icon.tsx @@ -0,0 +1,65 @@ +import { Tooltip } from 'primereact/tooltip'; + +import type { FC } from 'react'; + +import './Icon.scss'; + +export interface IconProps { + /** for PrimeReact icon */ + iconName?: string; + id: string; + /** for custom icon components */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + icon?: any; + size?: string; + disabled?: boolean; + onClick?: (e: React.MouseEvent) => void; + className?: string; + style?: React.CSSProperties; + showToolTip?: boolean; + toolTipContent?: string; + toolTipPosition?: 'bottom' | 'left' | 'right' | 'top'; +} + +export const Icon: FC = ({ + iconName, + id, + // eslint-disable-next-line @typescript-eslint/naming-convention + icon: IconCom, + size = '16px', + disabled = false, + onClick, + style, + className = '', + showToolTip = true, + toolTipContent, + toolTipPosition = 'bottom', +}) => { + const iconStyle = { + width: size, + height: size, + fontSize: size, + ['--icon-size' as string]: size, + } as React.CSSProperties & Record; + return ( +
    { + if (!disabled) onClick?.(event); + }} + id={`icon-${id}`} + > + {showToolTip && ( + + )} + {IconCom ? ( + + + + ) : ( + + )} +
    + ); +}; diff --git a/client/src/components/Editor/components/TooltipInput/index.scss b/packages/editor-core/src/components/TooltipInput/index.scss similarity index 66% rename from client/src/components/Editor/components/TooltipInput/index.scss rename to packages/editor-core/src/components/TooltipInput/index.scss index 8f98355..80c365e 100644 --- a/client/src/components/Editor/components/TooltipInput/index.scss +++ b/packages/editor-core/src/components/TooltipInput/index.scss @@ -1,13 +1,12 @@ -@use '@/utils/utils.scss' as *; +@use '../../styles/variables' as *; .tooltip-input { - .p-tooltip-arrow { - display: none; - } + .p-tooltip-arrow { display: none; } .p-tooltip-text { background-color: $backgroundColor; padding: 10px; - @include flex-center(); + display: flex; + align-items: center; input { height: 24px; margin-right: 1rem; @@ -15,4 +14,4 @@ color: $contentTextColor; } } -} \ No newline at end of file +} diff --git a/packages/editor-core/src/components/TooltipInput/index.tsx b/packages/editor-core/src/components/TooltipInput/index.tsx new file mode 100644 index 0000000..832f682 --- /dev/null +++ b/packages/editor-core/src/components/TooltipInput/index.tsx @@ -0,0 +1,38 @@ +import { InputText } from 'primereact/inputtext'; +import { Tooltip, type TooltipProps } from 'primereact/tooltip'; + +import { Icon, type IconProps } from '../Icon'; + +import type { FC } from 'react'; + +import './index.scss'; + +export interface TooltipInputProps { + tooltipOptions: TooltipProps; + iconOptions: IconProps; + value: string; + placeholder?: string; + onChange: (value: string) => void; + onConfirm: () => void; +} + +export const TooltipInput: FC = ({ + tooltipOptions, + iconOptions, + value, + placeholder, + onChange, + onConfirm, +}) => ( + + { + onChange(event.target.value); + }} + /> + + +); diff --git a/packages/editor-core/src/components/index.ts b/packages/editor-core/src/components/index.ts new file mode 100644 index 0000000..0c81d03 --- /dev/null +++ b/packages/editor-core/src/components/index.ts @@ -0,0 +1,6 @@ +export { ColorPicker } from './ColorPicker'; +export type { ColorOptions, ColorPickerProps } from './ColorPicker'; +export { Icon } from './Icon'; +export type { IconProps } from './Icon'; +export { TooltipInput } from './TooltipInput'; +export type { TooltipInputProps } from './TooltipInput'; diff --git a/client/src/components/Editor/configs/crepeFeatures.ts b/packages/editor-core/src/configs/crepeFeatures.ts similarity index 82% rename from client/src/components/Editor/configs/crepeFeatures.ts rename to packages/editor-core/src/configs/crepeFeatures.ts index bc53fa1..c253cfd 100644 --- a/client/src/components/Editor/configs/crepeFeatures.ts +++ b/packages/editor-core/src/configs/crepeFeatures.ts @@ -1,24 +1,26 @@ import { commandsCtx } from '@milkdown/kit/core'; import { - clearTextInCurrentBlockCommand, addBlockTypeCommand, - isMarkSelectedCommand, + clearTextInCurrentBlockCommand, codeBlockSchema, + isMarkSelectedCommand, wrapInBlockTypeCommand, } from '@milkdown/kit/preset/commonmark'; import { githubDark, githubLight } from '@uiw/codemirror-theme-github'; import mermaid from 'mermaid'; -import { containerSchema, ContainerType } from '../plugins/plugin-container'; +import { ContainerType, containerSchema } from '../plugins/plugin-container'; import { highlightSchema, showColorPickerCommand } from '../plugins/plugin-highlight'; import { iframeBlockSchema } from '../plugins/plugin-iframe'; +import { nextTick, uid } from '../utils'; import type { BlockEditFeatureConfig } from '@milkdown/crepe/feature/block-edit'; import type { CodeMirrorFeatureConfig } from '@milkdown/crepe/feature/code-mirror'; import type { ToolbarFeatureConfig } from '@milkdown/crepe/feature/toolbar'; -import Toast from '@/utils/Toast'; -import { nextTick, uid } from '@/utils/utils'; +export interface ConfigCallbacks { + onToast?: (message: string) => void; +} export const getBlockEditConfig = (): BlockEditFeatureConfig => ({ buildMenu(builder) { @@ -44,7 +46,6 @@ export const getBlockEditConfig = (): BlockEditFeatureConfig => ({ onRun: (ctx) => { const commands = ctx.get(commandsCtx); const codeBlock = codeBlockSchema.type(ctx); - commands.call(clearTextInCurrentBlockCommand.key); commands.call(addBlockTypeCommand.key, { nodeType: codeBlock, @@ -54,22 +55,17 @@ export const getBlockEditConfig = (): BlockEditFeatureConfig => ({ }); const containerGroup = builder.addGroup('container', 'Container'); - const containerItems = Object.values(ContainerType); - - containerItems.forEach((item) => { + Object.values(ContainerType).forEach((item) => { containerGroup.addItem(item, { icon: '', label: item.toUpperCase(), onRun: (ctx) => { const commands = ctx.get(commandsCtx); const container = containerSchema.type(ctx); - commands.call(clearTextInCurrentBlockCommand.key); commands.call(wrapInBlockTypeCommand.key, { nodeType: container, - attrs: { - containerType: item, - }, + attrs: { containerType: item }, }); }, }); @@ -77,7 +73,7 @@ export const getBlockEditConfig = (): BlockEditFeatureConfig => ({ }, }); -export const getCodeMirrorConfig = (isDarkMode: boolean): CodeMirrorFeatureConfig => { +export const getCodeMirrorConfig = (isDarkMode: boolean, callbacks: ConfigCallbacks = {}): CodeMirrorFeatureConfig => { mermaid.initialize({ suppressErrorRendering: true, startOnLoad: false, theme: isDarkMode ? 'dark' : 'neutral' }); return { @@ -85,32 +81,27 @@ export const getCodeMirrorConfig = (isDarkMode: boolean): CodeMirrorFeatureConfi renderPreview: (language, content, applyPreview) => { if (language === 'mermaid' && content.trim()) { const container = document.createElement('div'); - const containerId = `mermaid-preview-container-${uid() as string}`; + const containerId = `mermaid-preview-container-${uid()}`; container.setAttribute('id', containerId); - const placeholderDom = document.createElement('div'); placeholderDom.setAttribute('style', 'width: 100%; height: 250px;'); - const placeholderDomId = `mermaid-preview-${uid() as string}`; + const placeholderDomId = `mermaid-preview-${uid()}`; placeholderDom.setAttribute('id', placeholderDomId); container.appendChild(placeholderDom); const renderMermaid = async () => { try { const { svg, bindFunctions } = await mermaid.render(placeholderDomId, content); - applyPreview(container); - nextTick(() => { - // the container dom will be santified by applyPreview to another new dom - const santifiedContainer = document.getElementById(containerId); - if (!santifiedContainer) return; - - santifiedContainer.innerHTML = svg; - bindFunctions?.(santifiedContainer); + const sanitizedContainer = document.getElementById(containerId); + if (!sanitizedContainer) return; + sanitizedContainer.innerHTML = svg; + bindFunctions?.(sanitizedContainer); }); - } catch (e) { + } catch (error) { container.innerHTML = `

    Error: ${ - (e as Error).message + (error as Error).message }

    `; } }; @@ -118,10 +109,9 @@ export const getCodeMirrorConfig = (isDarkMode: boolean): CodeMirrorFeatureConfi void renderMermaid(); return; } - return null; }, - onCopy: () => Toast('Code copied'), + onCopy: () => callbacks.onToast?.('Code copied'), }; }; diff --git a/packages/editor-core/src/configs/index.ts b/packages/editor-core/src/configs/index.ts new file mode 100644 index 0000000..3ab1d6b --- /dev/null +++ b/packages/editor-core/src/configs/index.ts @@ -0,0 +1,2 @@ +export * from './crepeFeatures'; +export * from './uploadConfig'; diff --git a/packages/editor-core/src/configs/uploadConfig.ts b/packages/editor-core/src/configs/uploadConfig.ts new file mode 100644 index 0000000..f52ac4c --- /dev/null +++ b/packages/editor-core/src/configs/uploadConfig.ts @@ -0,0 +1,47 @@ +import { inlineImageConfig } from '@milkdown/kit/component/image-inline'; +import { uploadConfig, type Uploader } from '@milkdown/kit/plugin/upload'; + +import type { Ctx } from '@milkdown/kit/ctx'; +import type { Node } from '@milkdown/kit/prose/model'; + +export type ImageUploader = (file: File) => Promise; + +export interface ImageUploadOptions { + uploadImage?: ImageUploader; + getImageUrl?: (url: string) => string; +} + +const unavailableUploader: ImageUploader = () => { + throw new Error('Image uploading is not configured for this editor.'); +}; + +export const getImageUrl = (url: string) => url; + +export async function uploadImage(file: File) { + return unavailableUploader(file); +} + +export function configureImageUploader(ctx: Ctx, options: ImageUploadOptions = {}) { + const uploader = options.uploadImage ?? unavailableUploader; + const resolveImageUrl = options.getImageUrl ?? getImageUrl; + const uploadFiles: Uploader = async (files, schema) => { + const images = [...Array.from(files)].filter((file) => file.type.includes('image')); + const nodes: Node[] = await Promise.all( + images.map( + async (image) => + schema.nodes.image.createAndFill({ + src: await uploader(image), + alt: image.name, + })!, + ), + ); + return nodes; + }; + + ctx.update(uploadConfig.key, (previous) => ({ ...previous, uploader: uploadFiles })); + ctx.update(inlineImageConfig.key, (previous) => ({ + ...previous, + proxyDomURL: resolveImageUrl, + onUpload: uploader, + })); +} diff --git a/client/src/components/Editor/crepe.ts b/packages/editor-core/src/crepe.ts similarity index 53% rename from client/src/components/Editor/crepe.ts rename to packages/editor-core/src/crepe.ts index f219ee4..28039f4 100644 --- a/client/src/components/Editor/crepe.ts +++ b/packages/editor-core/src/crepe.ts @@ -2,47 +2,46 @@ import { Crepe } from '@milkdown/crepe'; import { listener } from '@milkdown/kit/plugin/listener'; import { upload } from '@milkdown/kit/plugin/upload'; -import { getBlockEditConfig, getCodeMirrorConfig, getToolbarConfig } from './configs/crepeFeatures'; -import { configureImageUploader, getImageUrl, uploadImage } from './configs/uploadConfig'; +import { + getBlockEditConfig, + getCodeMirrorConfig, + getToolbarConfig, + type ConfigCallbacks, +} from './configs/crepeFeatures'; +import { + configureImageUploader, + uploadImage as defaultUploadImage, + type ImageUploadOptions, +} from './configs/uploadConfig'; import { containerPlugin } from './plugins/plugin-container'; import { headingPlugin } from './plugins/plugin-heading'; import { configureColorPicker, highlightMarkerPlugin } from './plugins/plugin-highlight'; import { iframePlugin } from './plugins/plugin-iframe'; -import Toast from '@/utils/Toast'; - -export function getCrepe({ - root, - defaultValue, - isDarkMode, -}: { +export interface CrepeOptions extends ConfigCallbacks, ImageUploadOptions { root: HTMLElement; defaultValue: string; isDarkMode: boolean; -}) { +} + +export function getCrepe({ root, defaultValue, isDarkMode, onToast, uploadImage, getImageUrl }: CrepeOptions) { + const imageUploader = uploadImage ?? defaultUploadImage; + const imageUrlResolver = getImageUrl ?? ((url: string) => url); const crepe = new Crepe({ root, defaultValue, featureConfigs: { [Crepe.Feature.BlockEdit]: getBlockEditConfig(), [Crepe.Feature.Toolbar]: getToolbarConfig(), - [Crepe.Feature.CodeMirror]: getCodeMirrorConfig(isDarkMode), - [Crepe.Feature.LinkTooltip]: { - onCopyLink: () => { - Toast('Link copied'); - }, - }, - [Crepe.Feature.ImageBlock]: { - proxyDomURL: getImageUrl, - onUpload: uploadImage, - }, + [Crepe.Feature.CodeMirror]: getCodeMirrorConfig(isDarkMode, { onToast }), + [Crepe.Feature.LinkTooltip]: { onCopyLink: () => onToast?.('Link copied') }, + [Crepe.Feature.ImageBlock]: { proxyDomURL: imageUrlResolver, onUpload: imageUploader }, }, }); - crepe.editor .config((ctx) => { configureColorPicker(ctx); - configureImageUploader(ctx); + configureImageUploader(ctx, { uploadImage: imageUploader, getImageUrl: imageUrlResolver }); }) .use(upload) .use(headingPlugin) @@ -50,6 +49,5 @@ export function getCrepe({ .use(iframePlugin) .use(containerPlugin) .use(highlightMarkerPlugin); - return crepe; } diff --git a/packages/editor-core/src/global.d.ts b/packages/editor-core/src/global.d.ts new file mode 100644 index 0000000..875203d --- /dev/null +++ b/packages/editor-core/src/global.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.scss'; diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts new file mode 100644 index 0000000..19d7e98 --- /dev/null +++ b/packages/editor-core/src/index.ts @@ -0,0 +1,37 @@ +import '@milkdown/crepe/theme/common/style.css'; +import '@milkdown/crepe/theme/frame.css'; + +import './styles/index.scss'; + +export { editorViewCtx } from '@milkdown/kit/core'; +export type { Ctx } from '@milkdown/kit/ctx'; +export { outline } from '@milkdown/utils'; +export { CrepeEditor } from './MilkdownEditor'; +export type { CrepeEditorProps, CrepeEditorRef, EditorHeading } from './MilkdownEditor'; +export { getCrepe } from './crepe'; +export type { CrepeOptions } from './crepe'; +export type { ImageUploadOptions, ImageUploader } from './configs/uploadConfig'; +export { configureImageUploader, getImageUrl, uploadImage } from './configs/uploadConfig'; +export { + applyHighlightAndScroll, + mountEditorAddons, + removeEvents, + searchAndHighlight, + syncMirror, +} from './mountedAddons'; +export { ColorPicker, Icon, TooltipInput } from './components'; +export type { ColorPickerProps, ColorOptions, IconProps, TooltipInputProps } from './components'; +export { getBlockEditConfig, getCodeMirrorConfig, getToolbarConfig } from './configs/crepeFeatures'; +export { ContainerType, containerPlugin, containerSchema, wrapInContainerCommand } from './plugins/plugin-container'; +export type { ContainerNodeAttrs } from './plugins/plugin-container'; +export { Anchor, headingConfig, headingPlugin } from './plugins/plugin-heading'; +export type { AnchorProps, HeadingConfig } from './plugins/plugin-heading'; +export { + configureColorPicker, + colorOptionsConfig, + highlightMarkerPlugin, + highlightSchema, + DEFAULT_COLOR, +} from './plugins/plugin-highlight'; +export { iframeBlockSchema, iframePlugin } from './plugins/plugin-iframe'; +export { defaultColorOptions } from './plugins/plugin-highlight'; diff --git a/packages/editor-core/src/mountedAddons.tsx b/packages/editor-core/src/mountedAddons.tsx new file mode 100644 index 0000000..a2922cd --- /dev/null +++ b/packages/editor-core/src/mountedAddons.tsx @@ -0,0 +1,170 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { Decoration, DecorationSet, type EditorView } from '@milkdown/kit/prose/view'; + +import { throttle } from './utils'; + +export interface EditorAddonOptions { + readonly: boolean; + initialScrollTop?: number; + getScrollContainer?: () => HTMLElement | null; + getMirrorContainer?: () => HTMLElement | null; + onScroll?: (scrollTop: number) => void; + onAnchorChange?: (anchor: string) => void; + onBlurChange?: (isBlurred: boolean) => void; +} + +let removers: (() => void)[] = []; + +export function removeEvents() { + removers.forEach((remove) => { + remove(); + }); + removers = []; +} + +function scrollToAnchor(anchor: string, getScrollContainer?: () => HTMLElement | null) { + const element = document.getElementById(anchor); + const container = getScrollContainer?.(); + if (element && container) container.scrollTo({ top: element.offsetTop, behavior: 'auto' }); +} + +function syncAnchor(onAnchorChange?: (anchor: string) => void) { + document.querySelectorAll('.milkdown-heading').forEach((heading) => { + const rect = heading.getBoundingClientRect(); + if (rect.top > 0 && rect.top < 150) onAnchorChange?.(heading.id); + }); +} + +export function mountEditorAddons(options: EditorAddonOptions) { + removeEvents(); + const scrollContainer = options.getScrollContainer?.(); + if (scrollContainer) { + scrollContainer.scrollTop = options.initialScrollTop ?? 0; + const onScroll = throttle(() => { + options.onScroll?.(scrollContainer.scrollTop); + syncAnchor(options.onAnchorChange); + }, 100); + scrollContainer.addEventListener('scroll', onScroll); + removers.push(() => { + scrollContainer.removeEventListener('scroll', onScroll); + }); + setTimeout(() => { + syncAnchor(options.onAnchorChange); + }, 100); + } + + const editorDom = document.querySelector('.milkdown'); + if (editorDom) { + const enter = () => options.onBlurChange?.(false); + const leave = () => options.onBlurChange?.(true); + editorDom.addEventListener('mouseenter', enter); + editorDom.addEventListener('mouseleave', leave); + removers.push(() => { + editorDom.removeEventListener('mouseenter', enter); + editorDom.removeEventListener('mouseleave', leave); + }); + } + + const hash = decodeURIComponent(window.location.hash.slice(1)); + if (hash) scrollToAnchor(hash, options.getScrollContainer); + options.onAnchorChange?.(hash); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + if (options.readonly) syncMirror(options.getMirrorContainer); +} + +export function syncMirror(getMirrorContainer?: () => HTMLElement | null) { + const editorDom = document.querySelector('.milkdown .editor'); + const mirror = getMirrorContainer?.() ?? document.querySelector('.code-mirror-container .cm-content')?.parentElement; + if (!editorDom || !mirror) return; + const blocks = [...editorDom.children] as HTMLElement[]; + const lines = blocks.map((_, index) => { + let line = 0; + for (let i = 0; i < index; i++) line += blocks[i].innerText.split('\n').length + 1; + return line; + }); + blocks.forEach((block, index) => { + const onDoubleClick = (event: MouseEvent) => { + const line = [...mirror.querySelectorAll('.cm-line')][lines[index]] as HTMLElement | undefined; + if (!line) return; + const height = Number.parseFloat(getComputedStyle(line).height); + const scroller = mirror.closest('.cm-scroller'); + scroller?.scrollTo({ top: Math.max(0, (lines[index] - 3) * height), behavior: 'smooth' }); + event.stopPropagation(); + }; + block.addEventListener('dblclick', onDoubleClick); + removers.push(() => { + block.removeEventListener('dblclick', onDoubleClick); + }); + }); +} + +const HIGHLIGHT_DURATION_MS = 3500; + +export function applyHighlightAndScroll( + view: EditorView, + from: number, + to: number, + getScrollContainer?: () => HTMLElement | null, +) { + const container = getScrollContainer?.(); + if (container) { + try { + const coords = view.coordsAtPos(from); + const rect = container.getBoundingClientRect(); + container.scrollTo({ top: container.scrollTop + coords.top - rect.top - rect.height / 3, behavior: 'instant' }); + } catch { + // The editor may still be mounting. + } + } + view.setProps({ + decorations: (state) => + DecorationSet.create(state.doc, [Decoration.inline(from, to, { class: 'search-match-highlight' })]), + }); + setTimeout(() => { + if (view.dom.isConnected) view.setProps({ decorations: () => DecorationSet.empty }); + }, HIGHLIGHT_DURATION_MS); +} + +const stripMarkdownSyntax = (text: string) => + text + .replace(/^#{1,6}\s+/, '') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`(.+?)`/g, '$1') + .replace(/^\s*[-*+]\s+/, '') + .replace(/^\s*\d+\.\s+/, '') + .replace(/^\s*>\s+/, '') + .replace(/\[(.+?)\]\(.+?\)/g, '$1') + .trim(); + +export function searchAndHighlight( + view: EditorView, + query: string, + lineContent: string, + getScrollContainer?: () => HTMLElement | null, +) { + if (!query) return false; + const lowerQuery = query.toLowerCase(); + const line = stripMarkdownSyntax(lineContent).toLowerCase(); + const matches: { from: number; to: number; blockText: string }[] = []; + view.state.doc.descendants((node, pos) => { + if (!node.isTextblock) return; + const text = node.textContent.toLowerCase(); + let start = 0; + while (start < text.length) { + const index = text.indexOf(lowerQuery, start); + if (index === -1) break; + matches.push({ from: pos + 1 + index, to: pos + 1 + index + query.length, blockText: text }); + start = index + 1; + } + return false; + }); + if (!matches.length) return false; + const match = + matches.find((item) => line && (line.includes(item.blockText) || item.blockText.includes(line))) ?? matches[0]; + applyHighlightAndScroll(view, match.from, match.to, getScrollContainer); + return true; +} diff --git a/packages/editor-core/src/plugins/index.ts b/packages/editor-core/src/plugins/index.ts new file mode 100644 index 0000000..d78e5b3 --- /dev/null +++ b/packages/editor-core/src/plugins/index.ts @@ -0,0 +1,4 @@ +export * from './plugin-container'; +export * from './plugin-heading'; +export * from './plugin-highlight'; +export * from './plugin-iframe'; diff --git a/packages/editor-core/src/plugins/plugin-container/ContainerTitle.tsx b/packages/editor-core/src/plugins/plugin-container/ContainerTitle.tsx new file mode 100644 index 0000000..a3efbde --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-container/ContainerTitle.tsx @@ -0,0 +1,123 @@ +import { ListBox } from 'primereact/listbox'; +import { type FC, useEffect, useId, useMemo, useRef, useState } from 'react'; + +import { ContainerType } from './types'; +import { TooltipInput } from '../../components/TooltipInput'; + +export interface ContainerTitleProps { + desc?: string; + containerType: ContainerType; + contentDom: HTMLElement; + getReadonly: () => boolean; + setAttrs?: (attrs: { containerType: ContainerType; desc: string }) => void; +} + +export const ContainerTitle: FC = ({ + desc = '', + containerType, + contentDom, + getReadonly, + setAttrs, +}) => { + const uid = useId(); + const isDetails = containerType === ContainerType.DETAILS; + const title = desc || (isDetails ? 'Details' : containerType.toUpperCase()); + const [showOperations, setShowOperations] = useState(false); + const [showSelectorMenu, setShowSelectorMenu] = useState(false); + const [showEditDesc, setEditDesc] = useState(false); + const [descInput, setDescInput] = useState(desc); + const iconRef = useRef(null); + const listContainerRef = useRef(null); + + useEffect(() => { + setDescInput(desc); + }, [desc]); + useEffect(() => { + const clickHandler = (event: MouseEvent) => { + if (!listContainerRef.current?.contains(event.target as Node)) setShowSelectorMenu(false); + }; + window.addEventListener('click', clickHandler); + return () => { + window.removeEventListener('click', clickHandler); + }; + }, []); + + const showSelectorIcon = useMemo( + () => (showOperations || showSelectorMenu) && !getReadonly(), + [showOperations, showSelectorMenu, getReadonly], + ); + const showEditDescIcon = useMemo( + () => (showOperations || showEditDesc) && !getReadonly(), + [showOperations, showEditDesc, getReadonly], + ); + const onToggle = () => { + if (containerType !== ContainerType.DETAILS) return; + iconRef.current?.classList.toggle('pi-angle-down'); + iconRef.current?.classList.toggle('pi-angle-right'); + contentDom.classList.toggle('container-content-hidden'); + }; + + return ( + { + setShowOperations(true); + }} + onMouseLeave={() => { + setShowOperations(false); + }} + > + {isDetails && } +
    + {title} + {showSelectorIcon && ( +
    + { + event.stopPropagation(); + setShowSelectorMenu(!showSelectorMenu); + }} + > + {showSelectorMenu && ( +
    { + event.stopPropagation(); + }} + > + setAttrs?.({ containerType: event.value, desc: descInput })} + options={Object.values(ContainerType)} + /> +
    + )} +
    + {showEditDescIcon && ( + { + event.stopPropagation(); + setEditDesc(!showEditDesc); + }} + id={`desc-editor-${uid}`} + > + setAttrs?.({ containerType, desc: descInput })} + /> + + )} +
    + )} +
    +
    + ); +}; diff --git a/packages/editor-core/src/plugins/plugin-container/index.scss b/packages/editor-core/src/plugins/plugin-container/index.scss new file mode 100644 index 0000000..c6bff18 --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-container/index.scss @@ -0,0 +1,30 @@ +@use '../../styles/variables' as *; +@use '../../components/TooltipInput'; + +$tipBackgroundColor: #646cff24; +$infoBackgroundColor: #8e96aa24; +$warningBackgroundColor: #eab30824; +$dangerBackgroundColor: #f43f5e24; +$detailsBackgroundColor: #8e96aa24; + +.milkdown-container-block { + margin: 16px 0; + padding: 16px 16px 8px; + border-radius: 8px; + &-title { font-weight: 600; line-height: 24px; @include flex-center(); justify-content: flex-start; + .pi { margin-right: 3px; cursor: pointer; } + .title-container { position: relative; @include flex-center(); justify-content: flex-start; + .operations { margin-left: 5px; @include flex-center(); i { font-size: 12px; margin-right: 5px; } } + .list-container { position: absolute; z-index: 1000; } + .p-listbox-list-wrapper, .p-listbox-list, .p-listbox-item-group { border-radius: 5px; padding: .5rem 0; background-color: var(--crepe-color-surface); } + .p-listbox { box-shadow: var(--crepe-shadow-1); } + .p-listbox-item { padding: .5rem; font-weight: bold; } + } + } + .container-content-hidden { display: none; } + &[data-container-type='tip'] { background-color: $tipBackgroundColor; } + &[data-container-type='warning'] { background-color: $warningBackgroundColor; } + &[data-container-type='info'] { background-color: $infoBackgroundColor; } + &[data-container-type='danger'] { background-color: $dangerBackgroundColor; } + &[data-container-type='details'] { background-color: $detailsBackgroundColor; .milkdown-container-block-title { margin-bottom: 8px; cursor: pointer; } } +} diff --git a/packages/editor-core/src/plugins/plugin-container/index.ts b/packages/editor-core/src/plugins/plugin-container/index.ts new file mode 100644 index 0000000..210c88d --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-container/index.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { wrapIn } from '@milkdown/kit/prose/commands'; +import { $command, $inputRule, $nodeSchema, $view } from '@milkdown/utils'; +import { wrappingInputRule } from 'prosemirror-inputrules'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { ContainerTitle } from './ContainerTitle'; +import { ContainerType, type ContainerNodeAttrs } from './types'; + +import type { MilkdownPlugin } from '@milkdown/kit/ctx'; + +export * from './types'; + +export const containerSchema = $nodeSchema('container', () => ({ + content: 'block+', + group: 'block', + defining: true, + attrs: { + containerType: { + default: ContainerType.TIP, + validate: (value) => Object.values(ContainerType).includes(value as ContainerType), + }, + desc: { default: '', validate: 'string' }, + }, + parseDOM: [ + { + tag: 'div', + getAttrs: (dom) => ({ + containerType: dom.getAttribute('data-container-type') ?? ContainerType.TIP, + desc: dom.getAttribute('data-description') ?? '', + }), + }, + ], + parseMarkdown: { + match: (node) => + node.type === 'containerDirective' && Object.values(ContainerType).includes(node.name as ContainerType), + runner: (state, node, type) => + state + .openNode(type, { containerType: node.name, desc: (node.attributes as ContainerNodeAttrs).desc }) + .next(node.children) + .closeNode(), + }, + toMarkdown: { + match: (node) => node.type.name === 'container', + runner: (state, node) => { + const attributes: Record = {}; + if (node.attrs.desc) attributes.desc = node.attrs.desc; + state + .openNode('containerDirective', undefined, { name: node.attrs.containerType, attributes }) + .next(node.content) + .closeNode(); + }, + }, +})); + +const containerView = $view(containerSchema.node, () => (initialNode, view, getPos) => { + const { containerType, desc } = initialNode.attrs; + const contentDom = document.createElement('p'); + contentDom.classList.add('milkdown-container-block-content'); + if (containerType === ContainerType.DETAILS) contentDom.classList.add('container-content-hidden'); + const dom = document.createElement('div'); + dom.classList.add('milkdown-container-block'); + dom.setAttribute('data-container-type', containerType); + dom.setAttribute('data-description', desc); + const titleContainerDom = document.createElement('div'); + dom.append(titleContainerDom, contentDom); + const titleRoot = createRoot(titleContainerDom); + const setAttrs = (attrs: { containerType: ContainerType; desc: string }) => { + const pos = getPos(); + if (pos == null) return; + view.dispatch(view.state.tr.setNodeAttribute(pos, 'containerType', attrs.containerType.toLowerCase())); + const invalidDesc = Object.keys(ContainerType).includes(attrs.desc.trim().toUpperCase()); + view.dispatch(view.state.tr.setNodeAttribute(pos, 'desc', invalidDesc ? '' : attrs.desc)); + }; + const render = (nextDesc: string, nextType: ContainerType) => { + titleRoot.render( + createElement(ContainerTitle, { + desc: nextDesc, + containerType: nextType, + contentDom, + getReadonly: () => !view.editable, + setAttrs, + }), + ); + }; + render(desc, containerType); + return { + dom, + contentDOM: contentDom, + update: (updatedNode) => { + if ( + updatedNode.type !== initialNode.type || + updatedNode.attrs.desc !== desc || + updatedNode.attrs.containerType !== containerType + ) + return false; + render(updatedNode.attrs.desc, updatedNode.attrs.containerType); + return true; + }, + destroy: () => { + titleRoot.unmount(); + }, + ignoreMutation: (mutation) => mutation.type !== 'selection', + }; +}); + +const containerInputRule = $inputRule((ctx) => + wrappingInputRule(/^:::(tip|warning|info|danger|details)\s+([\S\s]+)\s?:::$/, containerSchema.type(ctx), (match) => ({ + containerType: match[1] || ContainerType.TIP, + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + desc: match[2] || '', + })), +); +export const wrapInContainerCommand = $command('WrapInContainer', (ctx) => () => wrapIn(containerSchema.type(ctx))); +export const containerPlugin: MilkdownPlugin[] = [containerSchema, containerView, containerInputRule].flat(); diff --git a/client/src/components/Editor/plugins/plugin-container/types.ts b/packages/editor-core/src/plugins/plugin-container/types.ts similarity index 100% rename from client/src/components/Editor/plugins/plugin-container/types.ts rename to packages/editor-core/src/plugins/plugin-container/types.ts diff --git a/packages/editor-core/src/plugins/plugin-heading/Anchor.tsx b/packages/editor-core/src/plugins/plugin-heading/Anchor.tsx new file mode 100644 index 0000000..f6f91ee --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-heading/Anchor.tsx @@ -0,0 +1,23 @@ +import { updateLocationHash } from '../../utils'; + +import type { FC } from 'react'; + +export interface AnchorProps { + id: string; + text: string; + toAnchor?: (id: string) => void; +} + +export const Anchor: FC = ({ id, text, toAnchor }) => ( + { + event.stopPropagation(); + if (toAnchor) toAnchor(id); + else updateLocationHash(id); + }} + className="heading-anchor" + aria-label={`Permalink to ${text}`} + > + # + +); diff --git a/client/src/components/Editor/plugins/plugin-heading/heading.scss b/packages/editor-core/src/plugins/plugin-heading/heading.scss similarity index 52% rename from client/src/components/Editor/plugins/plugin-heading/heading.scss rename to packages/editor-core/src/plugins/plugin-heading/heading.scss index 19ef326..a639a85 100644 --- a/client/src/components/Editor/plugins/plugin-heading/heading.scss +++ b/packages/editor-core/src/plugins/plugin-heading/heading.scss @@ -1,4 +1,4 @@ -@use '@/utils/utils.scss' as *; +@use '../../styles/variables' as *; .milkdown .ProseMirror { h1, h2, h3, h4, h5, h6 { @@ -9,39 +9,20 @@ line-height: 1.25; padding: 0; } - - & >.milkdown-heading:nth-child(1) { - margin-top: 0; - } - // when selected, the first child is .prosemirror-xx + & > .milkdown-heading:nth-child(1) { margin-top: 0; } div[contenteditable] { - & + .milkdown-heading { - margin-top: 0; - } - } - - h1.milkdown-heading { - font-size: 2em; - border-bottom: 1px solid $shadowColor; - padding-bottom: .3em; + & + .milkdown-heading { margin-top: 0; } } - h2.milkdown-heading { - font-size: 1.5em; + h1.milkdown-heading, h2.milkdown-heading { border-bottom: 1px solid $shadowColor; padding-bottom: .3em; } - h3.milkdown-heading { - font-size: 1.25em; - } - h4.milkdown-heading { - font-size: 1em; - } - h5.milkdown-heading { - font-size: 0.875em; - } - h6.milkdown-heading { - font-size: 0.75em; - } + h1.milkdown-heading { font-size: 2em; } + h2.milkdown-heading { font-size: 1.5em; } + h3.milkdown-heading { font-size: 1.25em; } + h4.milkdown-heading { font-size: 1em; } + h5.milkdown-heading { font-size: 0.875em; } + h6.milkdown-heading { font-size: 0.75em; } } .heading-anchor { @@ -62,9 +43,6 @@ display: inline-flex; @include flex-center(); z-index: 1000; - font-weight: normal; font-size: 20px; - &:hover { - opacity: 1; - } + &:hover { opacity: 1; } } diff --git a/packages/editor-core/src/plugins/plugin-heading/index.ts b/packages/editor-core/src/plugins/plugin-heading/index.ts new file mode 100644 index 0000000..af24483 --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-heading/index.ts @@ -0,0 +1,79 @@ +import { MilkdownPlugin } from '@milkdown/kit/ctx'; +import { headingAttr, headingSchema } from '@milkdown/kit/preset/commonmark'; +import { $ctx, $view } from '@milkdown/utils'; +import { createElement } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { Anchor } from './Anchor'; +import { headerToId } from '../../utils'; + +export interface HeadingConfig { + toAnchor?: (id: string) => void; +} + +export const headingDefaultConfig: HeadingConfig = {}; +export const headingConfig = $ctx(headingDefaultConfig, 'headingConfig'); + +export const headingView = $view(headingSchema.node, (ctx) => (initialNode) => { + const dom = document.createElement(`h${initialNode.attrs.level as string}`); + const attrs = { + ...ctx.get(headingAttr.key)(initialNode), + id: initialNode.attrs.id || headerToId(initialNode.textContent), + }; + Object.entries(attrs).forEach(([key, value]) => { + dom.setAttribute(key, value as string); + }); + dom.classList.add('milkdown-heading'); + + const contentDom = document.createElement('span'); + contentDom.textContent = initialNode.textContent; + dom.appendChild(contentDom); + + const anchorContainer = document.createElement('div'); + anchorContainer.classList.add('heading-anchor-container'); + dom.appendChild(anchorContainer); + const root = createRoot(anchorContainer); + const { toAnchor } = ctx.get(headingConfig.key); + + root.render( + createElement(Anchor, { + id: initialNode.attrs.id as string, + text: initialNode.textContent, + toAnchor, + }), + ); + + return { + dom, + contentDOM: contentDom, + update: (updatedNode) => { + if (updatedNode.type !== initialNode.type) return false; + dom.setAttribute('id', updatedNode.attrs.id); + root.render( + createElement(Anchor, { + id: updatedNode.attrs.id as string, + text: updatedNode.textContent, + toAnchor, + }), + ); + return true; + }, + destroy() { + root.unmount(); + }, + ignoreMutation(mutation) { + if ( + mutation.type === 'childList' && + (mutation.target as HTMLElement).classList.contains('heading-anchor-container') + ) + return true; + if (mutation.type === 'attributes' && (mutation.target as HTMLElement).classList.contains('heading-anchor')) + return true; + return false; + }, + }; +}); + +export const headingPlugin: MilkdownPlugin[] = [headingView, headingConfig].flat(); +export { Anchor } from './Anchor'; +export type { AnchorProps } from './Anchor'; diff --git a/packages/editor-core/src/plugins/plugin-highlight/commnads.ts b/packages/editor-core/src/plugins/plugin-highlight/commnads.ts new file mode 100644 index 0000000..471e3ab --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-highlight/commnads.ts @@ -0,0 +1,39 @@ +import { Mark } from '@milkdown/kit/prose/model'; +import { $command } from '@milkdown/utils'; + +import { highlightSchema } from './index'; +import { colorPickTooltipAPI } from './slices'; + +export const toggleColorPickerCommand = $command('ToggleColorPicker', (ctx) => () => (state) => { + const { + doc, + selection: { from, to }, + } = state; + const markType = highlightSchema.type(ctx); + if (doc.rangeHasMark(from, to, markType)) { + ctx.get(colorPickTooltipAPI.key).removeColorPicker(from, to); + return true; + } + let mark: Mark | null = null; + doc.nodesBetween(from, to, (node) => { + if (!markType.isInSet(node.marks)) return; + mark = node.marks.find((item) => item.type === markType) ?? null; + }); + ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); + return true; +}); + +export const showColorPickerCommand = $command('ToggleColorPicker', (ctx) => () => (state) => { + const { + doc, + selection: { from, to }, + } = state; + const markType = highlightSchema.type(ctx); + let mark: Mark | null = null; + doc.nodesBetween(from, to, (node) => { + if (!markType.isInSet(node.marks)) return; + mark = node.marks.find((item) => item.type === markType) ?? null; + }); + ctx.get(colorPickTooltipAPI.key).addColorPicker(mark, from, to); + return true; +}); diff --git a/client/src/components/Editor/plugins/plugin-highlight/configration.ts b/packages/editor-core/src/plugins/plugin-highlight/configration.ts similarity index 73% rename from client/src/components/Editor/plugins/plugin-highlight/configration.ts rename to packages/editor-core/src/plugins/plugin-highlight/configration.ts index 7aa054f..edbedfe 100644 --- a/client/src/components/Editor/plugins/plugin-highlight/configration.ts +++ b/packages/editor-core/src/plugins/plugin-highlight/configration.ts @@ -6,17 +6,11 @@ import type { Ctx } from '@milkdown/kit/ctx'; export function configureColorPicker(ctx: Ctx) { let colorPickerTooltipView: ColorPickerTooltip | null = null; - ctx.update(colorPickTooltipAPI.key, (api) => ({ ...api, - addColorPicker: (mark, from, to) => { - colorPickerTooltipView?.show(mark, from, to); - }, - removeColorPicker: (from, to) => { - colorPickerTooltipView?.remove(from, to); - }, + addColorPicker: (mark, from, to) => colorPickerTooltipView?.show(mark, from, to), + removeColorPicker: (from, to) => colorPickerTooltipView?.remove(from, to), })); - ctx.set(colorPickTooltip.key, { view: (view) => { colorPickerTooltipView = new ColorPickerTooltip(ctx, view); diff --git a/packages/editor-core/src/plugins/plugin-highlight/index.ts b/packages/editor-core/src/plugins/plugin-highlight/index.ts new file mode 100644 index 0000000..6ae3652 --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-highlight/index.ts @@ -0,0 +1,58 @@ +import { MilkdownPlugin } from '@milkdown/kit/ctx'; +import { markRule } from '@milkdown/kit/prose'; +import { $inputRule, $markSchema, $remark } from '@milkdown/kit/utils'; +import { Mark } from 'mdast'; + +import { showColorPickerCommand, toggleColorPickerCommand } from './commnads'; +import { remarkMarkColor } from './remark-mark-color'; +import { colorPickTooltipAPI, colorOptionsConfig } from './slices'; +import { colorPickTooltip } from './tooltip'; + +export const DEFAULT_COLOR = '#ffff00'; + +export const highlightSchema = $markSchema('mark', () => ({ + attrs: { color: { default: DEFAULT_COLOR, validate: 'string' } }, + parseDOM: [{ tag: 'mark', getAttrs: (node: HTMLElement) => ({ color: node.style.backgroundColor }) }], + toDOM: (mark) => ['mark', { style: `background-color: ${mark.attrs.color as string}` }], + parseMarkdown: { + match: (node) => node.type === 'mark', + runner: (state, node, markType) => { + const color = (node as Mark).data?.color; + state.openMark(markType, { color }); + state.next(node.children); + state.closeMark(markType); + }, + }, + toMarkdown: { + match: (node) => node.type.name === 'mark', + runner: (state, mark) => { + let color = mark.attrs.color; + if (color?.toLowerCase() === DEFAULT_COLOR.toLowerCase()) color = undefined; + state.withMark(mark, 'mark', undefined, { data: { color } }); + }, + }, +})); + +export const highlightInputRule = $inputRule((ctx) => + markRule(/(?:==)(?:\{([^}]+)\})?([^=]+)(?:==)$/, highlightSchema.type(ctx), { + getAttr: (match) => ({ color: match[1] || null }), + }), +); + +const markRemark = $remark('highlightColor', () => remarkMarkColor); +export const highlightMarkerPlugin: MilkdownPlugin[] = [ + highlightSchema, + highlightInputRule, + markRemark, + colorPickTooltip, + colorPickTooltipAPI, + colorOptionsConfig, + toggleColorPickerCommand, + showColorPickerCommand, +].flat(); + +export * from './configration'; +export * from './slices'; +export * from './commnads'; +export { ColorPickerTooltip } from './view'; +export type { ColorOptions } from './view/ColorPicker'; diff --git a/client/src/components/Editor/plugins/plugin-highlight/remark-mark-color.ts b/packages/editor-core/src/plugins/plugin-highlight/remark-mark-color.ts similarity index 68% rename from client/src/components/Editor/plugins/plugin-highlight/remark-mark-color.ts rename to packages/editor-core/src/plugins/plugin-highlight/remark-mark-color.ts index e6fa711..0b0aea8 100644 --- a/client/src/components/Editor/plugins/plugin-highlight/remark-mark-color.ts +++ b/packages/editor-core/src/plugins/plugin-highlight/remark-mark-color.ts @@ -16,20 +16,15 @@ declare module 'mdast-util-to-markdown' { declare module 'mdast' { export interface Mark extends Parent { type: 'mark'; - data: { - color?: string; - }; + data: { color?: string }; children: PhrasingContent[]; } - export interface StaticPhrasingContentMap { mark: Mark; } - interface PhrasingContentMap { mark: Mark; } - interface RootContentMap { mark: Mark; } @@ -41,30 +36,19 @@ function add( // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents value: FromMarkdownExtension | FromMarkdownExtension[] | MicromarkExtension | ToMarkdownExtension, ) { - // @ts-expect-error error + // @ts-expect-error extension arrays are intentionally supplied by the plugin const list = (data[field] = data[field] ?? []); if (Array.isArray(value)) { - for (const v of value) { - if (!list.includes(v)) list.push(v); - } - } else { - if (!list.includes(value)) list.push(value); - } + for (const item of value) if (!list.includes(item)) list.push(item); + } else if (!list.includes(value)) list.push(value); } -/** - * Serialize `mark` node back to markdown. - * Courtesy of mdast-util-highlight-mark implementation but extended for `{color}`. - */ const handleMarkColor: Handle = (node, _, state, info) => { const marker = '=='; const tracker = state.createTracker(info); const exit = state.enter('mark'); let value = tracker.move(marker); - // prepend color if any - if (node.data?.color) { - value += tracker.move(`{${node.data.color as string}}`); - } + if (node.data?.color) value += tracker.move(`{${node.data.color as string}}`); value += tracker.move(state.containerPhrasing(node, { before: value, after: marker, ...tracker.current() })); value += tracker.move(marker); exit(); @@ -73,7 +57,6 @@ const handleMarkColor: Handle = (node, _, state, info) => { const markColorToMarkdown: ToMarkdownExtension = { unsafe: [ - // Copy from highlight-mark's unsafe definition so `==` inside phrasing escapes correctly { character: '=', inConstruct: 'phrasing', @@ -87,18 +70,14 @@ const markColorToMarkdown: ToMarkdownExtension = { ], }, ], - handlers: { - mark: handleMarkColor, - }, + handlers: { mark: handleMarkColor }, }; function enterMark(this: CompileContext, token: Token) { - // `this` is CompileContext this.enter({ type: 'mark', children: [], data: {} }, token); } function exitMark(this: CompileContext, token: Token) { - // node is currently on top of the stack const node = this.stack[this.stack.length - 1] as Parent; if (node?.children?.length) { const first = node.children[0]; @@ -107,61 +86,40 @@ function exitMark(this: CompileContext, token: Token) { if (match) { node.data = { ...(node.data ?? {}), color: match[1] }; first.value = first.value.slice(match[0].length); - if (first.value.length === 0) node.children.shift(); + if (!first.value.length) node.children.shift(); } } } this.exit(token); } -// ---------- FromMarkdown (token -> mdast) ---------- const markColorFromMarkdown: FromMarkdownExtension = { canContainEols: ['mark'], enter: { highlight: enterMark }, exit: { highlight: exitMark }, }; -/** - * remark plugin to support ==highlighted text== syntax, optionally with a color: =={#ff0}text== - * - * It recognises highlights in the markdown and converts them to `mark` nodes in mdast. - * A `mark` node looks like: - * { - * type: 'mark', - * data: { color?: string }, - * children: [...] - * } - * - * The plugin also teaches `remark-stringify` how to turn those nodes back into markdown. - */ export function remarkMarkColor(this: Processor) { const data = this.data(); - // Register micromark + mdast extensions at parser stage add(data, 'micromarkExtensions', highlightMark()); add(data, 'fromMarkdownExtensions', markColorFromMarkdown); add(data, 'toMarkdownExtensions', markColorToMarkdown); - - // Transformer: convert `highlight` -> `mark` & parse color return (tree: Root) => { visit(tree, 'highlight', (node: Parent & RootContent, index: number, parent: Parent) => { if (!parent) return; const first = node.children[0]; - let color: string | undefined = undefined; + // eslint-disable-next-line @typescript-eslint/init-declarations + let color: string | undefined; if (first?.type === 'text') { const match = /^\{([^}]+)\}/.exec(first.value); if (match) { color = match[1]; first.value = first.value.slice(match[0].length); - if (first.value.length === 0) { - node.children.shift(); - } + if (!first.value.length) node.children.shift(); } } node.type = 'mark'; - if (color) { - node.data = { ...node.data, color }; - } - // update in parent + if (color) node.data = { ...node.data, color }; parent.children[index] = node; }); }; diff --git a/packages/editor-core/src/plugins/plugin-highlight/slices.ts b/packages/editor-core/src/plugins/plugin-highlight/slices.ts new file mode 100644 index 0000000..bd4715f --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-highlight/slices.ts @@ -0,0 +1,32 @@ +import { Mark } from '@milkdown/kit/prose/model'; +import { $ctx } from '@milkdown/utils'; + +import { ColorOptions } from './view/ColorPicker'; + +export interface ColorPickTooltipAPI { + addColorPicker: (mark: Mark | null, from: number, to: number) => void; + removeColorPicker: (from: number, to: number) => void; +} + +const defaultAPI: ColorPickTooltipAPI = { addColorPicker: () => undefined, removeColorPicker: () => undefined }; +export const colorPickTooltipAPI = $ctx({ ...defaultAPI }, 'colorPickTooltipAPICtx'); + +export const defaultColorOptions: ColorOptions[] = [ + { + label: 'Background', + code: 'BG', + items: [ + { label: 'Default', value: 'default' }, + { label: 'Red', value: '#fed5d5' }, + { label: 'Orange', value: '#fedfbb' }, + { label: 'Yellow', value: '#fef3a1' }, + { label: 'Green', value: '#e1fab1' }, + { label: 'Teal', value: '#adf8e9' }, + { label: 'Blue', value: '#cce2fe' }, + { label: 'Purple', value: '#edddff' }, + { label: 'Grey', value: '#eaecef' }, + ], + }, +]; + +export const colorOptionsConfig = $ctx(defaultColorOptions, 'colorOptionsCtx'); diff --git a/client/src/components/Editor/plugins/plugin-highlight/tooltip.ts b/packages/editor-core/src/plugins/plugin-highlight/tooltip.ts similarity index 100% rename from client/src/components/Editor/plugins/plugin-highlight/tooltip.ts rename to packages/editor-core/src/plugins/plugin-highlight/tooltip.ts diff --git a/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.scss b/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.scss new file mode 100644 index 0000000..7d72ff7 --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.scss @@ -0,0 +1,20 @@ +@use '../../../styles/variables' as *; + +.milkdown-color-picker { + position: absolute; + z-index: 10; + box-shadow: var(--crepe-shadow-1); + background-color: var(--crepe-color-surface); + border-radius: $borderRadius; + &[data-show='false'] { display: none; } + .color-picker-container { width: 150px; padding: 10px; + .group-title { font-size: 12px; color: $shallowTextColor; text-align: start; font-weight: normal; margin-bottom: .5rem; } + .color-item { @include flex-center(); justify-content: flex-start; padding: 5px; border-radius: $borderRadius; + .demo { border: 1px solid $shadowColor; border-radius: $borderRadius; padding: 3px; width: 16px; height: 16px; font-size: 12px; font-weight: normal; margin-right: 1rem; @include flex-center(); } + .desc { font-size: 12px; font-weight: normal; } + } + } + .color-picker-listbox { border: none; + .p-listbox-list-wrapper, .p-listbox-list, .p-listbox-item-group { background-color: var(--crepe-color-surface); } + } +} diff --git a/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.tsx b/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.tsx new file mode 100644 index 0000000..9becd5e --- /dev/null +++ b/packages/editor-core/src/plugins/plugin-highlight/view/ColorPicker.tsx @@ -0,0 +1,2 @@ +export { ColorPicker } from '../../../components/ColorPicker'; +export type { ColorOptions, ColorPickerProps } from '../../../components/ColorPicker'; diff --git a/client/src/components/Editor/plugins/plugin-highlight/view/index.ts b/packages/editor-core/src/plugins/plugin-highlight/view/index.ts similarity index 58% rename from client/src/components/Editor/plugins/plugin-highlight/view/index.ts rename to packages/editor-core/src/plugins/plugin-highlight/view/index.ts index 37f4594..959f9be 100644 --- a/client/src/components/Editor/plugins/plugin-highlight/view/index.ts +++ b/packages/editor-core/src/plugins/plugin-highlight/view/index.ts @@ -15,133 +15,68 @@ import type { EditorView } from '@milkdown/kit/prose/view'; import './ColorPicker.scss'; -interface Data { - from: number; - to: number; - mark: Mark | null; -} - -const defaultData: Data = { - from: -1, - to: -1, - mark: null, -}; +interface Data { from: number; to: number; mark: Mark | null; } +const defaultData: Data = { from: -1, to: -1, mark: null }; export class ColorPickerTooltip implements PluginView { #content: HTMLElement; - #provider: TooltipProvider; - #data: Data = { ...defaultData }; - #root: Root; - // #color: string; - constructor(private readonly ctx: Ctx, view: EditorView) { const content = document.createElement('div'); content.className = 'milkdown-color-picker'; - this.#content = content; - this.#root = createRoot(content); - this.#root.render( - createElement(ColorPicker, { - onColorChange: this.#onColorChange, - initialColor: '', - colorOptions: [], - }), - ); - - this.#provider = new TooltipProvider({ - content, - floatingUIOptions: { - placement: 'bottom', - }, - debounce: 0, - shouldShow: () => false, - }); - this.#provider.onHide = () => { - requestAnimationFrame(() => { - view.dom.focus({ preventScroll: true }); - }); - }; + this.#root.render(createElement(ColorPicker, { onColorChange: this.#onColorChange, initialColor: '', colorOptions: [] })); + this.#provider = new TooltipProvider({ content, floatingUIOptions: { placement: 'bottom' }, debounce: 0, shouldShow: () => false }); + this.#provider.onHide = () => requestAnimationFrame(() => view.dom.focus({ preventScroll: true })); this.#provider.update(view); } public update = (view: EditorView) => { - const { state } = view; - const { selection } = state; + const { selection } = view.state; if (!(selection instanceof TextSelection)) return; const { from, to } = selection; if (from === this.#data.from && to === this.#data.to) return; - this.#reset(); }; - public destroy = () => { - this.#provider.destroy(); - this.#content.remove(); - }; + public destroy = () => { this.#provider.destroy(); this.#content.remove(); }; public show = (mark: Mark | null, from: number, to: number) => { - this.#data = { - from, - to, - mark, - }; - // this.#color = mark.attrs.color; + this.#data = { from, to, mark }; const view = this.ctx.get(editorViewCtx); view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, from, to))); - this.#root.render( - createElement(ColorPicker, { - onColorChange: this.#onColorChange, - initialColor: mark?.attrs.color ?? '', - colorOptions: this.ctx.get(colorOptionsConfig.key), - }), - ); - this.#provider.show({ - getBoundingClientRect: () => posToDOMRect(view, from, to), - }); + this.#root.render(createElement(ColorPicker, { + onColorChange: this.#onColorChange, + initialColor: mark?.attrs.color ?? '', + colorOptions: this.ctx.get(colorOptionsConfig.key), + })); + this.#provider.show({ getBoundingClientRect: () => posToDOMRect(view, from, to) }); }; public remove = (from: number, to: number) => { const view = this.ctx.get(editorViewCtx); - const tr = view.state.tr; tr.removeMark(from, to, highlightSchema.type(this.ctx)); view.dispatch(tr); - this.#reset(); }; - #reset = () => { - this.#provider.hide(); - this.#data = { ...defaultData }; - }; + #reset = () => { this.#provider.hide(); this.#data = { ...defaultData }; }; #onColorChange = (color: string) => { const { from, to, mark } = this.#data; - - if (color === 'default') { - this.#reset(); - this.remove(from, to); - return; - } - + if (color === 'default') { this.#reset(); this.remove(from, to); return; } const view = this.ctx.get(editorViewCtx); const type = highlightSchema.type(this.ctx); - if (mark?.attrs?.color === color) { - this.#reset(); - return; - } - + if (mark?.attrs?.color === color) { this.#reset(); return; } const tr = view.state.tr; if (mark) tr.removeMark(from, to, mark); - tr.addMark(from, to, type.create({ color })); view.dispatch(tr); - this.#reset(); }; } diff --git a/client/src/components/Editor/plugins/plugin-iframe/IframeView.tsx b/packages/editor-core/src/plugins/plugin-iframe/IframeView.tsx similarity index 73% rename from client/src/components/Editor/plugins/plugin-iframe/IframeView.tsx rename to packages/editor-core/src/plugins/plugin-iframe/IframeView.tsx index 2ac4f6f..d801faa 100644 --- a/client/src/components/Editor/plugins/plugin-iframe/IframeView.tsx +++ b/packages/editor-core/src/plugins/plugin-iframe/IframeView.tsx @@ -1,7 +1,7 @@ import { ProgressSpinner } from 'primereact/progressspinner'; -import { FC, useEffect, useId, useRef, useState } from 'react'; +import { type FC, useEffect, useId, useRef, useState } from 'react'; -import { TooltipInput } from '@/components/Editor/components/TooltipInput'; +import { TooltipInput } from '../../components/TooltipInput'; interface IframeViewProps { src: string; @@ -13,32 +13,22 @@ export const IframeView: FC = ({ src, setAttrs, readonly = true const uid = useId(); const [inputSrc, setInputSrc] = useState(src); const [loading, setLoading] = useState(true); - const linkRef = useRef(null); useEffect(() => { setLoading(true); }, [src]); - const showEdit = !(loading || readonly); return (
    {showEdit && ( { - setInputSrc(value); - }} - onConfirm={() => { - setAttrs?.({ src: inputSrc }); - }} + onChange={setInputSrc} + onConfirm={() => setAttrs?.({ src: inputSrc })} /> )} directive); + +export const iframeBlockSchema = $nodeSchema('iframe', () => ({ + group: 'block', + atom: true, + isolating: true, + marks: '', + attrs: { src: { default: 'https://example.com' } }, + defining: true, + code: true, + parseDOM: [{ tag: 'iframe', getAttrs: (dom) => ({ src: dom.getAttribute('src') }) }], + parseMarkdown: { + match: (node) => node.type === 'leafDirective' && node.name === 'iframe', + runner: (state, node, type) => state.addNode(type, { src: (node.attributes as { src: string }).src }), + }, + toMarkdown: { + match: (node) => node.type.name === 'iframe', + runner: (state, node) => + state.addNode('leafDirective', undefined, undefined, { + name: 'iframe', + attributes: { src: node.attrs.src }, + }), + }, +})); + +const iframeView = $view(iframeBlockSchema.node, () => (initialNode, view, getPos) => { + const dom = document.createElement('div'); + dom.style.borderRadius = '5px'; + const root = createRoot(dom); + const setAttrs = ({ src }: { src: string }) => { + if (!view.editable) return; + const pos = getPos(); + if (pos == null) return; + view.dispatch(view.state.tr.setNodeAttribute(pos, 'src', src)); + }; + const render = (src: string) => { + root.render(createElement(IframeView, { readonly: !view.editable, src, setAttrs })); + }; + render(initialNode.attrs.src); + return { + dom, + update: (updatedNode) => { + if (updatedNode.type !== initialNode.type) return false; + render(updatedNode.attrs.src); + return true; + }, + destroy() { + root.unmount(); + }, + }; +}); + +const iframeInputRule = $inputRule( + (ctx) => + new InputRule(/:iframe\{src="(?[^"]+)?"?\}/, (state, match, start, end) => { + const [okay, src = ''] = match; + const { tr } = state; + if (okay) tr.replaceWith(start - 1, end, iframeBlockSchema.type(ctx).create({ src })); + return tr; + }), +); + +export const iframePlugin: MilkdownPlugin[] = [remarkDirective, iframeBlockSchema, iframeView, iframeInputRule].flat(); diff --git a/packages/editor-core/src/styles/editor.scss b/packages/editor-core/src/styles/editor.scss new file mode 100644 index 0000000..97b6f24 --- /dev/null +++ b/packages/editor-core/src/styles/editor.scss @@ -0,0 +1,57 @@ +@use './variables' as *; + +.milkdown .ProseMirror { + code { + padding: .1em .4em; + border-radius: 6px; + font-size: 80%; + line-height: 1.5; + font-family: var(--code-font-family, monospace); + } + .image-container img { + width: auto; + max-height: 35rem; + object-fit: cover; + } + .milkdown-code-block { + position: relative; + border-radius: $borderRadius; + margin-top: 0; + margin-bottom: 1rem; + } + .milkdown-image-block { + border-radius: $borderRadius; + img { border-radius: $borderRadius; } + } + blockquote { + padding: 0 1em; + margin-top: 0; + margin-bottom: 1rem; + &::before { height: 100%; top: 50%; transform: translateY(-50%); } + p { margin: 0; } + } + p { font-size: 16px; margin: 1rem 0; padding: 0; strong { font-weight: 500; } } + ul, ol { margin-bottom: 16px; } + .milkdown-list-item-block + .milkdown-list-item-block { margin-top: .25em; } + .milkdown-list-item-block .list-item .label-wrapper { + height: 24px; + .milkdown-icon.label { height: 24px; padding: 0; } + .milkdown-icon.label.readonly { &.bullet, &.ordered { cursor: unset; } } + } + .milkdown-list-item-block .children p { margin: 0; } + .milkdown-code-block p { font-size: unset; margin-bottom: unset; line-height: unset; } +} + +.search-match-highlight { + border-radius: 2px; + animation: search-highlight-flash 1.2s ease-in-out; + background-color: $searchHighlightColorSoft; +} + +@keyframes search-highlight-flash { + 0% { background-color: $searchHighlightColorStrong; } + 25% { background-color: transparent; } + 50% { background-color: $searchHighlightColorStrong; } + 75% { background-color: transparent; } + 100% { background-color: $searchHighlightColorSoft; } +} diff --git a/packages/editor-core/src/styles/index.scss b/packages/editor-core/src/styles/index.scss new file mode 100644 index 0000000..8d46af6 --- /dev/null +++ b/packages/editor-core/src/styles/index.scss @@ -0,0 +1,8 @@ +@use './variables' as *; +@use '../components/Icon' as iconStyles; +@use '../components/TooltipInput'; +@use '../plugins/plugin-container/index' as containerStyles; +@use '../plugins/plugin-heading/heading' as headingStyles; +@use '../plugins/plugin-highlight/view/ColorPicker' as highlightStyles; +@use '../plugins/plugin-iframe/index' as iframeStyles; +@use './editor'; diff --git a/packages/editor-core/src/styles/variables.scss b/packages/editor-core/src/styles/variables.scss new file mode 100644 index 0000000..a2b56be --- /dev/null +++ b/packages/editor-core/src/styles/variables.scss @@ -0,0 +1,19 @@ +@mixin flex-center { + display: flex; + justify-content: center; + align-items: center; +} + +$backgroundColor: var(--backgroundColor, #95a5a6); +$contentTextColor: var(--contentTextColor, #e6e6e6); +$shadowColor: var(--shadowColor, rgba(74, 74, 78, .12)); +$shallowTextColor: var(--shallowTextColor, #9D9FA6); +$hoverBgcColor: var(--hoverBgcColor, #D3D7DC); +$buttonBgcColor: var(--buttonBgcColor); +$buttonHoverBgcColor: var(--buttonHoverBgcColor); +$anchorColor: var(--anchorColor); +$borderRadius: 5px; +$transition: all 0.2s ease-in-out; +$editorHeight: 100%; +$searchHighlightColorStrong: var(--searchHighlightColorStrong, rgba(255, 235, 59, .6)); +$searchHighlightColorSoft: var(--searchHighlightColorSoft, rgba(255, 235, 59, .4)); diff --git a/packages/editor-core/src/utils.ts b/packages/editor-core/src/utils.ts new file mode 100644 index 0000000..ab8585b --- /dev/null +++ b/packages/editor-core/src/utils.ts @@ -0,0 +1,34 @@ +export const headerToId = (value: string) => + value + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') + .replace(/[\s_]+/g, '-'); + +export const updateLocationHash = (id: string) => { + window.history.replaceState(null, '', `#${encodeURIComponent(id)}`); +}; + +export const throttle = (fn: () => void, delay: number) => { + let startTime = Date.now(); + let timer: ReturnType | null = null; + return () => { + const remain = delay - (Date.now() - startTime); + if (timer) clearTimeout(timer); + if (remain <= 0) { + fn(); + startTime = Date.now(); + return; + } + timer = setTimeout(() => { + fn(); + timer = null; + startTime = Date.now(); + }, remain); + }; +}; + +export const nextTick = (callback: () => void) => setTimeout(callback, 0); + +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export const uid = () => Math.random().toString(36).slice(2); diff --git a/packages/editor-core/tsconfig.json b/packages/editor-core/tsconfig.json new file mode 100644 index 0000000..0b6e9dd --- /dev/null +++ b/packages/editor-core/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e5a696..f869f87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,36 +77,21 @@ importers: '@codemirror/lang-markdown': specifier: 0.19.6 version: 0.19.6 - '@codemirror/language': - specifier: ^6.11.3 - version: 6.11.3 '@codemirror/language-data': specifier: 0.19.2 version: 0.19.2 - '@emotion/css': - specifier: 11.13.5 - version: 11.13.5 '@emotion/react': specifier: ^11.14.0 version: 11.14.0(@types/react@19.1.6)(react@19.1.0) '@emotion/styled': specifier: ^11.14.1 version: 11.14.1(@emotion/react@11.14.0)(@types/react@19.1.6)(react@19.1.0) + '@markdown-editor/core': + specifier: workspace:* + version: link:../packages/editor-core '@markdown-editor/github-workspace': specifier: workspace:* version: link:../packages/github-workspace - '@milkdown/crepe': - specifier: 7.21.2 - version: 7.21.2(typescript@7.0.2) - '@milkdown/kit': - specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) - '@milkdown/react': - specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@7.0.2) - '@milkdown/utils': - specifier: 7.21.2 - version: 7.21.2 '@mui/icons-material': specifier: ^7.2.0 version: 7.2.0(@mui/material@7.2.0)(@types/react@19.1.6)(react@19.1.0) @@ -116,42 +101,18 @@ importers: '@reduxjs/toolkit': specifier: ^1.7.1 version: 1.9.7(react-redux@9.2.0)(react@19.1.0) - '@uiw/codemirror-theme-github': - specifier: ^4.24.2 - version: 4.24.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1) '@uiw/react-codemirror': specifier: 4.5.3 version: 4.5.3(@babel/runtime@7.28.2)(react-dom@19.1.0)(react@19.1.0) '@uiw/react-split': specifier: ^5.9.3 version: 5.9.3(react-dom@19.1.0)(react@19.1.0) - clipboard: - specifier: ^2.0.11 - version: 2.0.11 - mdast-util-from-markdown: - specifier: ^2.0.2 - version: 2.0.2 - mdast-util-to-markdown: - specifier: ^2.1.2 - version: 2.1.2 - mermaid: - specifier: ^11.11.0 - version: 11.11.0 - micromark-extension-highlight-mark: - specifier: ^1.2.0 - version: 1.2.0 - micromark-util-types: - specifier: ^2.0.2 - version: 2.0.2 primeicons: specifier: ^7.0.0 version: 7.0.0 primereact: specifier: ^10.9.6 version: 10.9.6(@types/react@19.1.6)(react-dom@19.1.0)(react@19.1.0) - prosemirror-inputrules: - specifier: 1.5.1 - version: 1.5.1 react: specifier: 19.1.0 version: 19.1.0 @@ -167,43 +128,16 @@ importers: react-router-dom: specifier: ^7.6.1 version: 7.6.1(react-dom@19.1.0)(react@19.1.0) - refractor: - specifier: 4.9.0 - version: 4.9.0 - remark-directive: - specifier: ^4.0.0 - version: 4.0.0 - remark-highlight-mark: - specifier: ^1.4.0 - version: 1.4.0 - unified: - specifier: ^11.0.5 - version: 11.0.5 - unist-util-add: - specifier: ^1.2.0 - version: 1.2.0 - unist-util-visit: - specifier: ^5.0.0 - version: 5.0.0 - uuid: - specifier: ^13.0.0 - version: 13.0.0 devDependencies: '@rsbuild/core': specifier: ^1.3.21 version: 1.3.21 - '@rsbuild/plugin-less': - specifier: ^1.2.4 - version: 1.2.4(@rsbuild/core@1.3.21) '@rsbuild/plugin-react': specifier: ^1.3.1 version: 1.3.1(@rsbuild/core@1.3.21) '@rsbuild/plugin-sass': specifier: ^1.3.1 version: 1.3.1(@rsbuild/core@1.3.21) - '@types/mdast': - specifier: ^4.0.4 - version: 4.0.4 '@types/node': specifier: ^24.10.1 version: 24.10.1 @@ -213,9 +147,6 @@ importers: '@types/react-dom': specifier: ^19.1.5 version: 19.1.5(@types/react@19.1.6) - '@types/react-redux': - specifier: 7.1.34 - version: 7.1.34 typescript: specifier: ^7.0.2 version: 7.0.2 @@ -238,6 +169,76 @@ importers: specifier: ^3.101.0 version: 3.114.17 + packages/editor-core: + dependencies: + '@milkdown/crepe': + specifier: 7.21.2 + version: 7.21.2(@typescript/typescript6@6.0.2) + '@milkdown/kit': + specifier: 7.21.2 + version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) + '@milkdown/react': + specifier: 7.21.2 + version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0) + '@milkdown/utils': + specifier: 7.21.2 + version: 7.21.2 + '@uiw/codemirror-theme-github': + specifier: ^4.24.2 + version: 4.24.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1) + mdast-util-from-markdown: + specifier: ^2.0.2 + version: 2.0.2 + mdast-util-to-markdown: + specifier: ^2.1.2 + version: 2.1.2 + mermaid: + specifier: ^11.11.0 + version: 11.11.0 + micromark-extension-highlight-mark: + specifier: ^1.2.0 + version: 1.2.0 + micromark-util-types: + specifier: ^2.0.2 + version: 2.0.2 + primereact: + specifier: ^10.9.6 + version: 10.9.6(@types/react@19.1.6)(react-dom@19.1.0)(react@19.1.0) + prosemirror-inputrules: + specifier: 1.5.1 + version: 1.5.1 + react: + specifier: '>=18' + version: 19.1.0 + react-dom: + specifier: '>=18' + version: 19.1.0(react@19.1.0) + remark-directive: + specifier: ^4.0.0 + version: 4.0.0 + remark-highlight-mark: + specifier: ^1.4.0 + version: 1.4.0 + unified: + specifier: ^11.0.5 + version: 11.0.5 + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 + devDependencies: + '@types/mdast': + specifier: ^4.0.4 + version: 4.0.4 + '@types/react': + specifier: ^19.1.6 + version: 19.1.6 + '@types/react-dom': + specifier: ^19.1.5 + version: 19.1.5(@types/react@19.1.6) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: /@typescript/typescript6@6.0.2 + packages/github-workspace: devDependencies: typescript: @@ -316,6 +317,7 @@ packages: /@babel/runtime@7.27.1: resolution: {integrity: sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==} engines: {node: '>=6.9.0'} + dev: false /@babel/runtime@7.28.2: resolution: {integrity: sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==} @@ -1171,18 +1173,6 @@ packages: stylis: 4.2.0 dev: false - /@emotion/css@11.13.5: - resolution: {integrity: sha512-wQdD0Xhkn3Qy2VNcIzbLP9MR8TafI0MJb7BEAXKp+w4+XqErksWR4OXomuDzPsN4InLdGhVe6EYcn2ZIUCpB8w==} - dependencies: - '@emotion/babel-plugin': 11.13.5 - '@emotion/cache': 11.14.0 - '@emotion/serialize': 1.3.3 - '@emotion/sheet': 1.4.0 - '@emotion/utils': 1.4.2 - transitivePeerDependencies: - - supports-color - dev: false - /@emotion/hash@0.9.2: resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} dev: false @@ -2233,7 +2223,7 @@ packages: langium: 3.3.1 dev: false - /@milkdown/components@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2): + /@milkdown/components@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-u3eCgRAnJgglZtJ6m5g09OF3SFPOagCe3xFvqtXRaUdX0/7E9z3hOvXkuisZNQUEF9623qwT/zNhK2rctvuFrg==} peerDependencies: '@codemirror/language': ^6 @@ -2260,7 +2250,7 @@ packages: lodash-es: 4.17.21 nanoid: 5.1.5 unist-util-visit: 5.0.0 - vue: 3.5.22(typescript@7.0.2) + vue: 3.5.22(@typescript/typescript6@6.0.2) transitivePeerDependencies: - supports-color - typescript @@ -2280,7 +2270,7 @@ packages: - supports-color dev: false - /@milkdown/crepe@7.21.2(typescript@7.0.2): + /@milkdown/crepe@7.21.2(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-3BaEADeWxVYTn6TwromOKnu/m5GLJvIKGl4VlHJ9dVpdh7VLEwLbggfw5nweQmpnGWEaqyDplk2uPH0xK5MMhg==} dependencies: '@codemirror/commands': 6.8.1 @@ -2289,7 +2279,7 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.37.1 - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) + '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) '@types/lodash-es': 4.17.12 clsx: 2.1.1 codemirror: 6.0.1 @@ -2299,7 +2289,7 @@ packages: prosemirror-virtual-cursor: 0.4.2 remark-math: 6.0.0 unist-util-visit: 5.0.0 - vue: 3.5.22(typescript@7.0.2) + vue: 3.5.22(@typescript/typescript6@6.0.2) transitivePeerDependencies: - prosemirror-model - prosemirror-state @@ -2318,10 +2308,10 @@ packages: resolution: {integrity: sha512-/6QkN1XmEtVHe5XhPm87lCUtV9qQNRHuAhxki/wtF+10vAeq8xOBW9/S5Kv/zpS9DSgdM/Ce+BPO3+yT1FIvWg==} dev: false - /@milkdown/kit@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2): + /@milkdown/kit@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-93E5PgJgQXTwn0N8xSmk3Y6+oVvMMQYDV3HF22ZNE8tFVRLOz3vMLJFfxncxY0CONadCpreoOT3Ut+p9JYjySA==} dependencies: - '@milkdown/components': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) + '@milkdown/components': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) '@milkdown/core': 7.21.2 '@milkdown/ctx': 7.21.2 '@milkdown/exception': 7.21.2 @@ -2542,14 +2532,14 @@ packages: prosemirror-view: 1.41.3 dev: false - /@milkdown/react@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(react-dom@19.1.0)(react@19.1.0)(typescript@7.0.2): + /@milkdown/react@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-JjRFfp/K/3aatEELQNngGMnpskqvY43zAe/b9kZRSVk4Fpt4QDYep6ISFiW/46BgoUw9EsOozT6JlEvWmsVimQ==} peerDependencies: react: '*' react-dom: '*' dependencies: - '@milkdown/crepe': 7.21.2(typescript@7.0.2) - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(typescript@7.0.2) + '@milkdown/crepe': 7.21.2(@typescript/typescript6@6.0.2) + '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: @@ -2960,16 +2950,6 @@ packages: jiti: 2.4.2 dev: true - /@rsbuild/plugin-less@1.2.4(@rsbuild/core@1.3.21): - resolution: {integrity: sha512-fanvE5K7DyncrbXaP6BohLBRDi0mizN9lknKhaHBNJQCD7J/yHfbXCxG1lWjReEwxyyUip6UOAypVnWIAmt4Ow==} - peerDependencies: - '@rsbuild/core': 1.x - dependencies: - '@rsbuild/core': 1.3.21 - deepmerge: 4.3.1 - reduce-configs: 1.1.0 - dev: true - /@rsbuild/plugin-react@1.3.1(@rsbuild/core@1.3.21): resolution: {integrity: sha512-1PfE0CZDwiSIUFaMFOEprwsHK6oo29zU6DdtFH2D49uLcpUdOUvU1u2p00RCVO1CIgnAjRajLS7dnPdQUwFOuQ==} peerDependencies: @@ -3315,25 +3295,12 @@ packages: resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} dev: false - /@types/hast@2.3.10: - resolution: {integrity: sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==} - dependencies: - '@types/unist': 2.0.11 - dev: false - /@types/hast@3.0.4: resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} dependencies: '@types/unist': 3.0.3 dev: false - /@types/hoist-non-react-statics@3.3.6: - resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} - dependencies: - '@types/react': 18.3.22 - hoist-non-react-statics: 3.3.2 - dev: true - /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true @@ -3375,14 +3342,6 @@ packages: resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} dev: false - /@types/prismjs@1.26.5: - resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} - dev: false - - /@types/prop-types@15.7.14: - resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==} - dev: true - /@types/prop-types@15.7.15: resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} dev: false @@ -3395,15 +3354,6 @@ packages: '@types/react': 19.1.6 dev: true - /@types/react-redux@7.1.34: - resolution: {integrity: sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==} - dependencies: - '@types/hoist-non-react-statics': 3.3.6 - '@types/react': 18.3.22 - hoist-non-react-statics: 3.3.2 - redux: 4.2.1 - dev: true - /@types/react-transition-group@4.4.12(@types/react@19.1.6): resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} peerDependencies: @@ -3412,13 +3362,6 @@ packages: '@types/react': 19.1.6 dev: false - /@types/react@18.3.22: - resolution: {integrity: sha512-vUhG0YmQZ7kL/tmKLrD3g5zXbXXreZXB3pmROW8bg3CnLnpjkRVwUlLne7Ufa2r9yJ8+/6B73RzhAek5TBKh2Q==} - dependencies: - '@types/prop-types': 15.7.14 - csstype: 3.1.3 - dev: true - /@types/react@19.1.6: resolution: {integrity: sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==} dependencies: @@ -3594,6 +3537,7 @@ packages: cpu: [ppc64] os: [aix] requiresBuild: true + dev: true optional: true /@typescript/typescript-darwin-arm64@7.0.2: @@ -3602,6 +3546,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: true optional: true /@typescript/typescript-darwin-x64@7.0.2: @@ -3610,6 +3555,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: true optional: true /@typescript/typescript-freebsd-arm64@7.0.2: @@ -3618,6 +3564,7 @@ packages: cpu: [arm64] os: [freebsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-freebsd-x64@7.0.2: @@ -3626,6 +3573,7 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-arm64@7.0.2: @@ -3634,6 +3582,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-arm@7.0.2: @@ -3642,6 +3591,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-loong64@7.0.2: @@ -3650,6 +3600,7 @@ packages: cpu: [loong64] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-mips64el@7.0.2: @@ -3658,6 +3609,7 @@ packages: cpu: [mips64el] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-ppc64@7.0.2: @@ -3666,6 +3618,7 @@ packages: cpu: [ppc64] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-riscv64@7.0.2: @@ -3674,6 +3627,7 @@ packages: cpu: [riscv64] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-s390x@7.0.2: @@ -3682,6 +3636,7 @@ packages: cpu: [s390x] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-linux-x64@7.0.2: @@ -3690,6 +3645,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: true optional: true /@typescript/typescript-netbsd-arm64@7.0.2: @@ -3698,6 +3654,7 @@ packages: cpu: [arm64] os: [netbsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-netbsd-x64@7.0.2: @@ -3706,6 +3663,7 @@ packages: cpu: [x64] os: [netbsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-openbsd-arm64@7.0.2: @@ -3714,6 +3672,7 @@ packages: cpu: [arm64] os: [openbsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-openbsd-x64@7.0.2: @@ -3722,6 +3681,7 @@ packages: cpu: [x64] os: [openbsd] requiresBuild: true + dev: true optional: true /@typescript/typescript-sunos-x64@7.0.2: @@ -3730,6 +3690,7 @@ packages: cpu: [x64] os: [sunos] requiresBuild: true + dev: true optional: true /@typescript/typescript-win32-arm64@7.0.2: @@ -3738,6 +3699,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: true optional: true /@typescript/typescript-win32-x64@7.0.2: @@ -3746,6 +3708,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: true optional: true /@typescript/typescript6@6.0.2: @@ -3753,7 +3716,6 @@ packages: hasBin: true dependencies: '@typescript/old': /typescript@6.0.3 - dev: true /@uiw/codemirror-theme-github@4.24.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1): resolution: {integrity: sha512-S43STd3jiyd6coraYW83vb6MK9POrwDXXUV1QJ3H/nZcdG6LrWGc3zWhhJIc99dtJ7zSIDvTnUkudJ27nZ5hwA==} @@ -3870,7 +3832,7 @@ packages: dependencies: '@vue/compiler-ssr': 3.5.22 '@vue/shared': 3.5.22 - vue: 3.5.22(typescript@7.0.2) + vue: 3.5.22(@typescript/typescript6@6.0.2) dev: false /@vue/shared@3.5.22: @@ -4090,14 +4052,6 @@ packages: lodash-es: 4.17.21 dev: false - /clipboard@2.0.11: - resolution: {integrity: sha512-C+0bbOqkezLIsmWSvlsXS0Q0bmkugu7jcfMIACB+RDEntIzQIkdr148we28AfSloQLRdZlYL/QYyrq05j/3Faw==} - dependencies: - good-listener: 1.2.2 - select: 1.1.2 - tiny-emitter: 2.1.0 - dev: false - /cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} dependencies: @@ -4132,6 +4086,7 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + requiresBuild: true dev: true /color-string@1.9.1: @@ -4157,10 +4112,6 @@ packages: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} dev: true - /comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} - dev: false - /commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -4651,10 +4602,6 @@ packages: robust-predicates: 3.0.2 dev: false - /delegate@3.2.0: - resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==} - dev: false - /dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -5424,12 +5371,6 @@ packages: slash: 3.0.0 dev: true - /good-listener@1.2.2: - resolution: {integrity: sha1-1TswzfkxPf+33JoNR3CWqm0UXFA=} - dependencies: - delegate: 3.2.0 - dev: false - /gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} dependencies: @@ -5482,26 +5423,11 @@ packages: dependencies: function-bind: 1.1.1 - /hast-util-parse-selector@3.1.1: - resolution: {integrity: sha512-jdlwBjEexy1oGz0aJ2f4GKMaVKkA9jwjr4MjAAI22E5fM/TXVZHuS5OpONtdeIkRKqAaryQ2E9xNQxijoThSZA==} - dependencies: - '@types/hast': 2.3.10 - dev: false - - /hastscript@7.2.0: - resolution: {integrity: sha512-TtYPq24IldU8iKoJQqvZOuhi5CyCQRAbvDOX0x1eW6rsHSxa/1i2CCiptNTotGHJ3VoHRGmqiv6/D3q113ikkw==} - dependencies: - '@types/hast': 2.3.10 - comma-separated-tokens: 2.0.3 - hast-util-parse-selector: 3.1.1 - property-information: 6.5.0 - space-separated-tokens: 2.0.2 - dev: false - /hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} dependencies: react-is: 16.13.1 + dev: false /html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -6741,10 +6667,6 @@ packages: react-is: 16.13.1 dev: false - /property-information@6.5.0: - resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} - dev: false - /prosemirror-changeset@2.3.1: resolution: {integrity: sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==} dependencies: @@ -6908,6 +6830,7 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + dev: false /react-is@19.1.1: resolution: {integrity: sha512-tr41fA15Vn8p4X9ntI+yCyeGSf1TlYaY5vlTZfQmeLBrFo3psOPX6HhTDnFNL9uj3EhP0KAQ80cugCl4b4BERA==} @@ -7000,14 +6923,6 @@ packages: resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} dependencies: '@babel/runtime': 7.27.1 - - /refractor@4.9.0: - resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} - dependencies: - '@types/hast': 2.3.10 - '@types/prismjs': 1.26.5 - hastscript: 7.2.0 - parse-entities: 4.0.2 dev: false /regexp.prototype.flags@1.4.3: @@ -7402,10 +7317,6 @@ packages: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} dev: false - /select@1.1.2: - resolution: {integrity: sha1-DnNQrN7ICxEIUoeG7B1EGNEbOW0=} - dev: false - /semver@6.3.0: resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} hasBin: true @@ -7503,10 +7414,6 @@ packages: resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} dev: true - /space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - dev: false - /spawn-command@0.0.2-1: resolution: {integrity: sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=} dev: true @@ -7622,10 +7529,6 @@ packages: resolution: {integrity: sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=} dev: true - /tiny-emitter@2.1.0: - resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==} - dev: false - /tinyexec@1.0.1: resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==} dev: false @@ -7717,7 +7620,6 @@ packages: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true - dev: true /typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} @@ -7744,6 +7646,7 @@ packages: '@typescript/typescript-sunos-x64': 7.0.2 '@typescript/typescript-win32-arm64': 7.0.2 '@typescript/typescript-win32-x64': 7.0.2 + dev: true /ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -7852,11 +7755,6 @@ packages: hasBin: true dev: false - /uuid@13.0.0: - resolution: {integrity: sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==} - hasBin: true - dev: false - /uvu@0.5.6: resolution: {integrity: sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==} engines: {node: '>=8'} @@ -7943,7 +7841,7 @@ packages: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} dev: false - /vue@3.5.22(typescript@7.0.2): + /vue@3.5.22(@typescript/typescript6@6.0.2): resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==} peerDependencies: typescript: '*' @@ -7956,7 +7854,7 @@ packages: '@vue/runtime-dom': 3.5.22 '@vue/server-renderer': 3.5.22(vue@3.5.22) '@vue/shared': 3.5.22 - typescript: 7.0.2 + typescript: /@typescript/typescript6@6.0.2 dev: false /w3c-keyname@2.2.8: diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 240d6b1..b8985ba 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -4,6 +4,7 @@ "client/**/*.ts", "client/**/*.tsx", "packages/**/*.ts", + "packages/**/*.tsx", ".eslintrc.js", "scripts/**/*.ts", "github-auth-worker/**/*.ts" From 80fe567ab22a120add940ec1016f390ef145e264 Mon Sep 17 00:00:00 2001 From: s-elo Date: Mon, 7 Sep 2026 11:51:02 +0800 Subject: [PATCH 14/17] feat: upgrade milkdown kit to 7.22.1 --- .codegraph/.gitignore | 16 -- .gitignore | 2 + packages/editor-core/package.json | 10 +- pnpm-lock.yaml | 350 +++++++++++++++--------------- 4 files changed, 182 insertions(+), 196 deletions(-) delete mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore deleted file mode 100644 index 9de0f16..0000000 --- a/.codegraph/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty diff --git a/.gitignore b/.gitignore index bd9727c..b96f230 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,5 @@ editor-settings.json Markdown-Editor.app markdown-editor-macos.zip markdown-editor-windows.zip + +.zvec-grep diff --git a/packages/editor-core/package.json b/packages/editor-core/package.json index 3d8dfd1..755556d 100644 --- a/packages/editor-core/package.json +++ b/packages/editor-core/package.json @@ -18,10 +18,10 @@ "react-dom": ">=18" }, "dependencies": { - "@milkdown/crepe": "7.21.2", - "@milkdown/kit": "7.21.2", - "@milkdown/react": "7.21.2", - "@milkdown/utils": "7.21.2", + "@milkdown/crepe": "7.22.1", + "@milkdown/kit": "7.22.1", + "@milkdown/react": "7.22.1", + "@milkdown/utils": "7.22.1", "@uiw/codemirror-theme-github": "^4.24.2", "mdast-util-from-markdown": "^2.0.2", "mdast-util-to-markdown": "^2.1.2", @@ -41,4 +41,4 @@ "@types/mdast": "^4.0.4", "typescript": "npm:@typescript/typescript6@^6.0.2" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f869f87..2ee0051 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,17 +172,17 @@ importers: packages/editor-core: dependencies: '@milkdown/crepe': - specifier: 7.21.2 - version: 7.21.2(@typescript/typescript6@6.0.2) + specifier: 7.22.1 + version: 7.22.1(@typescript/typescript6@6.0.2) '@milkdown/kit': - specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) + specifier: 7.22.1 + version: 7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) '@milkdown/react': - specifier: 7.21.2 - version: 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0) + specifier: 7.22.1 + version: 7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0) '@milkdown/utils': - specifier: 7.21.2 - version: 7.21.2 + specifier: 7.22.1 + version: 7.22.1 '@uiw/codemirror-theme-github': specifier: ^4.24.2 version: 4.24.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1) @@ -2223,8 +2223,8 @@ packages: langium: 3.3.1 dev: false - /@milkdown/components@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): - resolution: {integrity: sha512-u3eCgRAnJgglZtJ6m5g09OF3SFPOagCe3xFvqtXRaUdX0/7E9z3hOvXkuisZNQUEF9623qwT/zNhK2rctvuFrg==} + /@milkdown/components@7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): + resolution: {integrity: sha512-6IA8fcFcBTm/x1X1typz73yUoT1JuN4srmifGAIeWEtCnayEwRjFxpQOoQrfvMgBYx4DSHcPVLhJUlR/xbBtxg==} peerDependencies: '@codemirror/language': ^6 '@codemirror/state': ^6 @@ -2234,21 +2234,21 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/view': 6.37.1 '@floating-ui/dom': 1.7.1 - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/plugin-diff': 7.21.2 - '@milkdown/plugin-tooltip': 7.21.2 - '@milkdown/preset-commonmark': 7.21.2 - '@milkdown/preset-gfm': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/plugin-tooltip': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/preset-gfm': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 '@types/lodash-es': 4.17.12 clsx: 2.1.1 dompurify: 3.2.6 lodash-es: 4.17.21 - nanoid: 5.1.5 + nanoid: 6.0.1 unist-util-visit: 5.0.0 vue: 3.5.22(@typescript/typescript6@6.0.2) transitivePeerDependencies: @@ -2256,13 +2256,13 @@ packages: - typescript dev: false - /@milkdown/core@7.21.2: - resolution: {integrity: sha512-b9xrcN9g+o05skEc4O78vGPvwJm1sbfqIPROncZH2vSE/lOIcp9AR1SdqveWmhymd/QILgTzW9WsnJfgQkGMXQ==} + /@milkdown/core@7.22.1: + resolution: {integrity: sha512-X4O3al2aYugpFP/Do7VoPgvVEftjJVi+6UhZmz9fSdqMtpU70AitJ8R/erUA18hay2hWvNO9m3pxIJoR6no6KQ==} dependencies: - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 remark-parse: 11.0.0 remark-stringify: 11.0.0 unified: 11.0.5 @@ -2270,8 +2270,8 @@ packages: - supports-color dev: false - /@milkdown/crepe@7.21.2(@typescript/typescript6@6.0.2): - resolution: {integrity: sha512-3BaEADeWxVYTn6TwromOKnu/m5GLJvIKGl4VlHJ9dVpdh7VLEwLbggfw5nweQmpnGWEaqyDplk2uPH0xK5MMhg==} + /@milkdown/crepe@7.22.1(@typescript/typescript6@6.0.2): + resolution: {integrity: sha512-0BZTGBRV8lnlKsA3vmPK5Qyomjqd952493AIzfnbhiYwzHpluX03iMtOgHC6E2UcpMXP86P5CXhPUWjzDu+JZQ==} dependencies: '@codemirror/commands': 6.8.1 '@codemirror/language': 6.11.3 @@ -2279,12 +2279,12 @@ packages: '@codemirror/state': 6.5.2 '@codemirror/theme-one-dark': 6.1.2 '@codemirror/view': 6.37.1 - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) + '@milkdown/kit': 7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) '@types/lodash-es': 4.17.12 clsx: 2.1.1 codemirror: 6.0.1 dompurify: 3.2.6 - katex: 0.17.0 + katex: 0.18.4 lodash-es: 4.17.21 prosemirror-virtual-cursor: 0.4.2 remark-math: 6.0.0 @@ -2298,40 +2298,40 @@ packages: - typescript dev: false - /@milkdown/ctx@7.21.2: - resolution: {integrity: sha512-dqiA0GeERv86QEde+ZvxqBy4cDmfAsfLTIpi10doFJLKLerei23TVb5lHD8/CGUVqpS995MTu3dAFRBs9C0q5Q==} - dependencies: - '@milkdown/exception': 7.21.2 - dev: false - - /@milkdown/exception@7.21.2: - resolution: {integrity: sha512-/6QkN1XmEtVHe5XhPm87lCUtV9qQNRHuAhxki/wtF+10vAeq8xOBW9/S5Kv/zpS9DSgdM/Ce+BPO3+yT1FIvWg==} - dev: false - - /@milkdown/kit@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): - resolution: {integrity: sha512-93E5PgJgQXTwn0N8xSmk3Y6+oVvMMQYDV3HF22ZNE8tFVRLOz3vMLJFfxncxY0CONadCpreoOT3Ut+p9JYjySA==} - dependencies: - '@milkdown/components': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/plugin-block': 7.21.2 - '@milkdown/plugin-clipboard': 7.21.2 - '@milkdown/plugin-cursor': 7.21.2 - '@milkdown/plugin-diff': 7.21.2 - '@milkdown/plugin-history': 7.21.2 - '@milkdown/plugin-indent': 7.21.2 - '@milkdown/plugin-listener': 7.21.2 - '@milkdown/plugin-slash': 7.21.2 - '@milkdown/plugin-streaming': 7.21.2 - '@milkdown/plugin-tooltip': 7.21.2 - '@milkdown/plugin-trailing': 7.21.2 - '@milkdown/plugin-upload': 7.21.2 - '@milkdown/preset-commonmark': 7.21.2 - '@milkdown/preset-gfm': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - '@milkdown/utils': 7.21.2 + /@milkdown/ctx@7.22.1: + resolution: {integrity: sha512-6+LJdo3DcrkO1lzGcje0yWqaG6ZKgTR8kDxyDrrm7iocBjq3M+bkw04f99ZG/BLp1Rbc4yRyin2mUy6m4SEVaA==} + dependencies: + '@milkdown/exception': 7.22.1 + dev: false + + /@milkdown/exception@7.22.1: + resolution: {integrity: sha512-wXUfn+fpWy0jeJ6AVEjKPieouLfJIDLQ4IRqyEL3d5yZDiZTtKu1Va0+RprrvqANpGJ1YBRQ1uCgDE6sJgTEjQ==} + dev: false + + /@milkdown/kit@7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2): + resolution: {integrity: sha512-gXLMhjqe0j8XRSUe97LBXbpZ0sDi5EM6nnt1zXewUaGDS3mleMjNntSCWrp9ln40kz8tGs0ol50kSAr1n/JBnQ==} + dependencies: + '@milkdown/components': 7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/plugin-block': 7.22.1 + '@milkdown/plugin-clipboard': 7.22.1 + '@milkdown/plugin-cursor': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/plugin-history': 7.22.1 + '@milkdown/plugin-indent': 7.22.1 + '@milkdown/plugin-listener': 7.22.1 + '@milkdown/plugin-slash': 7.22.1 + '@milkdown/plugin-streaming': 7.22.1 + '@milkdown/plugin-tooltip': 7.22.1 + '@milkdown/plugin-trailing': 7.22.1 + '@milkdown/plugin-upload': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/preset-gfm': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - '@codemirror/language' - '@codemirror/state' @@ -2340,156 +2340,156 @@ packages: - typescript dev: false - /@milkdown/plugin-block@7.21.2: - resolution: {integrity: sha512-ZAv3QSr9J8+U4u8SVPcKeVeMmSA9OrrwS6jeY4YP/k6OxPfGK7H5+i6qW73gDxXvRzTGMSyTuFOdX4tgcXnV9A==} + /@milkdown/plugin-block@7.22.1: + resolution: {integrity: sha512-R0gOuKRqR1DyFULD75Oic0+57DXTthagUZbbB9lsHM59aQmxKJ1nRmujptPeMfDlDfEod2ao5rsow7LO8p+ZWQ==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-clipboard@7.21.2: - resolution: {integrity: sha512-Noc4rFpVzHvzjNaIKLhvzX2DEeyjWCTzDZR9k6B/0wgD0ZiZSGDnnq8op1Is1s5NjFq6UpCeZtXfs8HuLxbgFA==} + /@milkdown/plugin-clipboard@7.22.1: + resolution: {integrity: sha512-H1meyLEj2fOnFWSopeQ2H7hl6VXM+OQZoU9H7GrlHFM5GKtBFBErFFV4Cl5tUSVFY+xYy/4rEPOA23Ba1u6OfA==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-cursor@7.21.2: - resolution: {integrity: sha512-PDENEK8mZ44YTfQyR5fuqVk5ZF8CJLdYpcavcorfhtvVx+3ZpQodDz0q7o42kE17jEMXCfsL/t2gUHxOp3pPIw==} + /@milkdown/plugin-cursor@7.22.1: + resolution: {integrity: sha512-3tJzpQl5cyn+8vzsHkPBYsW6QacEN7yP2O5GkyUjrNLvMIQ5vVi7GUuztLeoouJKlOc1vjIq3h7ARXCgHAKe8g==} dependencies: - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 prosemirror-drop-indicator: 0.1.0 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-diff@7.21.2: - resolution: {integrity: sha512-I1aLHTAcKk04MFDA/RmhRYPY4kHoSjZf9yoCxl5eNYi3PtOzJ2aoKcBi/uNbU0TG+MoJc/DKizVcaI1t4jyAWg==} + /@milkdown/plugin-diff@7.22.1: + resolution: {integrity: sha512-qjuCMx11HwhMlTJuybqbFZT8PQkasSi0qICxq2HdYqH0WQuLO99I2mb17UAJz1aF+JM9rp1StHWr4bxC0ofamA==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-history@7.21.2: - resolution: {integrity: sha512-iymGm2fm/desC0tAukz3ubHZ7KSj0UFvHZTY8H55IzPnRLsVErnGFOZ5l/VFeHQI4xoBq+NWUBuPFFF4ApxBlw==} + /@milkdown/plugin-history@7.22.1: + resolution: {integrity: sha512-SRD6emVhfVmA6mrcCsCiWrONN/2Kp+VT5LLojIQxNc/spzjWw15PtkA3r4nUe9cZmNN5rE7XvrPaA0WnjJIyeQ==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-indent@7.21.2: - resolution: {integrity: sha512-CY7lOWZPckjgsLPhMKa/96lNSbr1Bf5xT8sUQ+rC6Vv1PgRy63R8dTO+99xuHTmZp7XZm33cuswEW7VJuPFA4g==} + /@milkdown/plugin-indent@7.22.1: + resolution: {integrity: sha512-s/wmqxaJpIKT01gEXsC9gCkzA3Clm+MNioZLzNCYD8xI/4Hf3AhmIEoEqf3LIQ9w10Zs2yhIxIrknNWfCOgsEw==} dependencies: - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-listener@7.21.2: - resolution: {integrity: sha512-CyVNlB5uyQlH7aAX1kBKF3O+2zATGwXmjWZ7qFLNyII+31Wv4jshAzxHSIBi4qrIWbqBEbT72j2OQTOCdRNLOA==} + /@milkdown/plugin-listener@7.22.1: + resolution: {integrity: sha512-6k8JMDrdAL0E0WygpDX1YOR0Q9Wc4pia8pIMN5nN5liEeAyNaM6LwAxXJY+awQJ0OstDKHTfjkh7WzJo4QiXOA==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-slash@7.21.2: - resolution: {integrity: sha512-da8UxNr73UC/32BQNZnWaalj0HjZIH2zIA6k7fHEgwwSXq3/aAgPc8x80/toHyVBMeeNA0hEcEobJLvEGFYV7g==} + /@milkdown/plugin-slash@7.22.1: + resolution: {integrity: sha512-eFKCjfgXfAQusTSOyzhz4nASquOApdiBy9hnGkPB/SJ6oltTC8SonB3nIxF/X0wPZHfn09vuz/JBqh9cyDtXJA==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-streaming@7.21.2: - resolution: {integrity: sha512-gWuMAz4HStGDdtQ2Al9pr35RXk2MzZonJy1ODFuzmFImLpBDAWJaEZkigFWQXijGff00IvS3nkXJSWZQBOesJg==} + /@milkdown/plugin-streaming@7.22.1: + resolution: {integrity: sha512-CR0lujyc7ae0sbjNfscvS+b+cUP7b6XJ90ioTor605UV+F8EKh5t4PMJM79IVzSUCeob51a+rtXA2LAA1b/GyA==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/plugin-diff': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/plugin-diff': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-tooltip@7.21.2: - resolution: {integrity: sha512-L8123LL4sv2nsSUN86dwmF9NXwH+TZFaS6bt7Dd43DjZgxb+HMfpDJCLEA9f4zrJgDUr1Tis501WZvQGzPMrTQ==} + /@milkdown/plugin-tooltip@7.22.1: + resolution: {integrity: sha512-drdWA/7WlrDr2B+ABYf4tY9xTiwg/CJMUCydfD05dR2d8wOkaF6oOHZwJbWnkWLMtAJWdieOYgfXGPhQRwXxCg==} dependencies: '@floating-ui/dom': 1.7.1 - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 '@types/lodash-es': 4.17.12 lodash-es: 4.17.21 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-trailing@7.21.2: - resolution: {integrity: sha512-G0hZs94ZwXsZ+skpuitLjBeAFt/3JJz4B4A5pE18xGruEZ2LAObhJ7+Y8sGgcAQvMfl3LcTbuggv3uU5Pr5xxA==} + /@milkdown/plugin-trailing@7.22.1: + resolution: {integrity: sha512-Buy8IX3I7wwq0QPfJRep77QnvlkBeg6BvDHkbh5eTMrgnwSiWhN+tV9RVCYpdWXM7QhQZpSjU2SejluLuTyVCg==} dependencies: - '@milkdown/ctx': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/ctx': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/plugin-upload@7.21.2: - resolution: {integrity: sha512-g3c0IXlZSJ9Bcg+iFzAHJ5lLHc6YHf6r1Wl8IQAIXWMh8G4p9N7yXoYknLuZGyT//7s1/mzcc/UI1Yq7kCGNWg==} + /@milkdown/plugin-upload@7.22.1: + resolution: {integrity: sha512-vV0bZzdh/PhM+Jk9Nk5cKHDXHY5jt+ePcaitTfvESDYfg83YZ9AglOhw/YclPWklMvswrnCGZZFTZD4daqc4hg==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/utils': 7.22.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/preset-commonmark@7.21.2: - resolution: {integrity: sha512-LpPltwT+2ywK0EufhEAm+YnMaTqohaIdiarDDVfvMjVz7cF3ciXOa2/SOsYK76+/vbyQaUizuIG0sooxYDpHqg==} + /@milkdown/preset-commonmark@7.22.1: + resolution: {integrity: sha512-it+G0YUG5MDXt0qLB6W083ss5i6tnWAXWCW8SgnmsehGxntkN5//gNM/8vh3rqwl8RvnBvjLWpJ6TgHSlBPhlw==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 remark-inline-links: 7.0.0 unist-util-visit: 5.0.0 unist-util-visit-parents: 6.0.1 @@ -2497,26 +2497,26 @@ packages: - supports-color dev: false - /@milkdown/preset-gfm@7.21.2: - resolution: {integrity: sha512-t39W/o2KzCCNdFu9D1GSsSsuEQSCCwM5YQjj16HaHjtUryScjy4epEyXoMffXfT2FOF5oiTdBf+HukvzKFaoqA==} + /@milkdown/preset-gfm@7.22.1: + resolution: {integrity: sha512-UPMdHRdMHlVourOOTiwsp3qHu614pDFWlWtVzxe8fyl8uKZUk6IOuewH5ubhqD/opos8OEB+zgEomUGfee1oTw==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/preset-commonmark': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - '@milkdown/utils': 7.21.2 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/preset-commonmark': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + '@milkdown/utils': 7.22.1 prosemirror-safari-ime-span: 1.0.2 remark-gfm: 4.0.1 transitivePeerDependencies: - supports-color dev: false - /@milkdown/prose@7.21.2: - resolution: {integrity: sha512-ufnHhRaevLbqISBpOA5Rl4+S66n+h0JRfoZw2j/WyJ1eBNHg42xoQNXUEI9YiP/GoQPKeOitKux50GpafyNtQw==} + /@milkdown/prose@7.22.1: + resolution: {integrity: sha512-fqgTHl94G7oDPY94cPYBm6ARxEoLQ10ubkTNLD1nzAlTUfJLULZkIEMSMy4CECncw2go3ExW32CZPpXLJVSxnQ==} dependencies: - '@milkdown/exception': 7.21.2 + '@milkdown/exception': 7.22.1 prosemirror-changeset: 2.3.1 prosemirror-commands: 1.7.1 prosemirror-dropcursor: 1.8.2 @@ -2532,14 +2532,14 @@ packages: prosemirror-view: 1.41.3 dev: false - /@milkdown/react@7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0): - resolution: {integrity: sha512-JjRFfp/K/3aatEELQNngGMnpskqvY43zAe/b9kZRSVk4Fpt4QDYep6ISFiW/46BgoUw9EsOozT6JlEvWmsVimQ==} + /@milkdown/react@7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2)(react-dom@19.1.0)(react@19.1.0): + resolution: {integrity: sha512-X4rU5lKhLThni/fRLVZZ9b5D+RZsceTAalRzsBXpEjdIKYm+QlzIDfFILF0txSTkY3MldTLmG+zSxlCtV4lj3g==} peerDependencies: react: '*' react-dom: '*' dependencies: - '@milkdown/crepe': 7.21.2(@typescript/typescript6@6.0.2) - '@milkdown/kit': 7.21.2(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) + '@milkdown/crepe': 7.22.1(@typescript/typescript6@6.0.2) + '@milkdown/kit': 7.22.1(@codemirror/language@6.11.3)(@codemirror/state@6.5.2)(@codemirror/view@6.37.1)(@typescript/typescript6@6.0.2) react: 19.1.0 react-dom: 19.1.0(react@19.1.0) transitivePeerDependencies: @@ -2553,26 +2553,26 @@ packages: - typescript dev: false - /@milkdown/transformer@7.21.2: - resolution: {integrity: sha512-j2VQ6baKy6b6rAo/yo00YwSsWHoW0sqgwRTDqSCwXW8dgdFtl4M2i/73exRLcRQ4MvDOHrSqGS5RcRz+fzPOeQ==} + /@milkdown/transformer@7.22.1: + resolution: {integrity: sha512-rU1IBtxezNg7TSWaq+RP59/t6tfD3jIPGrwAtMKPIGuYEax+8d1fyGisp98TeI3l0VQEIgMpF+KlNDV+Y1HJVA==} dependencies: - '@milkdown/exception': 7.21.2 - '@milkdown/prose': 7.21.2 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 remark: 15.0.1 unified: 11.0.5 transitivePeerDependencies: - supports-color dev: false - /@milkdown/utils@7.21.2: - resolution: {integrity: sha512-m1ZJDnh+/tnIBxLhh5euNGr1WXvsGnJkaGctsd/an5+73HqHOdtWD6L7oJF8+DQG4Kksr4WPNNZutqYGb6OAxw==} + /@milkdown/utils@7.22.1: + resolution: {integrity: sha512-ye0LGy/Ez8fjtcYyYj93wp4XfH00JYpCiy1IDHYB+9HVIpnbr3ed7PvY4s2000xmDWe0Vlx07TOyv96yvFrnjQ==} dependencies: - '@milkdown/core': 7.21.2 - '@milkdown/ctx': 7.21.2 - '@milkdown/exception': 7.21.2 - '@milkdown/prose': 7.21.2 - '@milkdown/transformer': 7.21.2 - nanoid: 5.1.5 + '@milkdown/core': 7.22.1 + '@milkdown/ctx': 7.22.1 + '@milkdown/exception': 7.22.1 + '@milkdown/prose': 7.22.1 + '@milkdown/transformer': 7.22.1 + nanoid: 6.0.1 transitivePeerDependencies: - supports-color dev: false @@ -5731,8 +5731,8 @@ packages: commander: 8.3.0 dev: false - /katex@0.17.0: - resolution: {integrity: sha512-Vdw0ATsQ9V+LuegM/BTwQqV/6cTl5lbGcIrU+BCgLxyf6bo38ybOr372tuSIxir3CN720flu1meYR6XzNMwQnw==} + /katex@0.18.4: + resolution: {integrity: sha512-IMPntbRLOU+eu88XDiFKqQ8Akhr9Tv7jDMXqPhjG9SI1JMA4DIgXk4x9k4skJz2NZJXBRbC+2pYBLj9olqcZow==} hasBin: true dependencies: commander: 8.3.0 @@ -6410,9 +6410,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - /nanoid@5.1.5: - resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} - engines: {node: ^18 || >=20} + /nanoid@6.0.1: + resolution: {integrity: sha512-3wVS3i51pE2pi1k5FFL/95BGfVS0kSsvDVuGXHOtxox/TywUmtgq+3qiTOTbs9J7KfHaXPiN171k/A6dBnaXFw==} + engines: {node: ^22 || ^24 || >=26} hasBin: true dev: false From 87e586054da271532ff0ed4ed2f51fe1cbe11c1d Mon Sep 17 00:00:00 2001 From: s-elo Date: Mon, 7 Sep 2026 18:20:30 +0800 Subject: [PATCH 15/17] chore: prepare search doc for github mode --- .../src/components/DocSearch/DocSearch.scss | 84 ++++- client/src/components/DocSearch/DocSearch.tsx | 296 ++++++++++++------ .../components/DocSearch/SearchResults.tsx | 53 +++- client/src/components/Header/Header.tsx | 20 +- client/src/redux-api/github.ts | 87 ++++- client/src/utils/hooks/searchHooks.ts | 148 +++++++++ client/src/utils/hooks/workspaceHooks.ts | 2 +- client/src/utils/workspaceSearch.ts | 140 +++++++++ 8 files changed, 722 insertions(+), 108 deletions(-) create mode 100644 client/src/utils/hooks/searchHooks.ts create mode 100644 client/src/utils/workspaceSearch.ts diff --git a/client/src/components/DocSearch/DocSearch.scss b/client/src/components/DocSearch/DocSearch.scss index 05bea49..6e8cca2 100644 --- a/client/src/components/DocSearch/DocSearch.scss +++ b/client/src/components/DocSearch/DocSearch.scss @@ -16,11 +16,16 @@ white-space: nowrap; transition: $transition; - &:hover { + &:hover:not(:disabled) { color: $headerTextColor; border-color: $hoverBorderColor; } + &:disabled { + cursor: not-allowed; + opacity: 0.45; + } + .search-trigger-icon { font-size: 0.85rem; } @@ -47,11 +52,11 @@ .doc-search-dialog { width: 600px; background-color: $backgroundColor; - .search-panel { display: flex; flex-direction: column; gap: 0.75rem; + padding-top: 1rem; } .search-mode-toggle.p-selectbutton { @@ -136,6 +141,30 @@ } } } + + .search-submit-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.35rem; + min-height: 38px; + padding: 0.35rem 0.75rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + background-color: $hoverBgcColor; + color: $headerTextColor; + cursor: pointer; + transition: $transition; + + &:hover:not(:disabled) { + border-color: $hoverBorderColor; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } } .search-filters-section { @@ -195,6 +224,57 @@ padding: 2rem 0; @include flex-center(); } + + .search-status { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + padding: 0.65rem 0.75rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + color: $shallowTextColor; + font-size: 0.82rem; + } + + .search-error { + border-color: rgba(220, 53, 69, 0.45); + color: #dc3545; + } + + .search-warning { + border-color: rgba(255, 193, 7, 0.45); + } + + .search-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 1rem; + color: $shallowTextColor; + font-size: 0.85rem; + } + + .load-more-btn { + display: block; + margin: 0.75rem auto 0.25rem; + padding: 0.35rem 0.9rem; + border: 1px solid $panelBorderColor; + border-radius: $borderRadius; + background: transparent; + color: $headerTextColor; + cursor: pointer; + + &:hover:not(:disabled) { + background-color: $hoverBgcColor; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } } .result-item { diff --git a/client/src/components/DocSearch/DocSearch.tsx b/client/src/components/DocSearch/DocSearch.tsx index 5016d5d..b9b595b 100644 --- a/client/src/components/DocSearch/DocSearch.tsx +++ b/client/src/components/DocSearch/DocSearch.tsx @@ -3,35 +3,36 @@ import { IconField } from 'primereact/iconfield'; import { InputIcon } from 'primereact/inputicon'; import { InputText } from 'primereact/inputtext'; import { SelectButton } from 'primereact/selectbutton'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { SearchResults } from './SearchResults'; -import { - FileContentMatches, - FileNameMatch, - useLazySearchContentQuery, - useLazySearchFilesQuery, -} from '@/redux-api/searchApi'; -import { useDebounce } from '@/utils/hooks/tools'; +import type { FileContentMatches, FileNameMatch } from '@/redux-api/searchApi'; + +import { selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; +import { useGitHubSearchAvailability, useWorkspaceSearch, WorkspaceSearchMode } from '@/utils/hooks/searchHooks'; import { normalizePath } from '@/utils/utils'; import './DocSearch.scss'; -const DEBOUNCE_MS = 350; - -type SearchMode = 'content' | 'files'; - const MODE_OPTIONS = [ { label: 'Files', value: 'files' }, { label: 'Content', value: 'content' }, ]; -export const DocSearch: FC = () => { +const GITHUB_SEARCH_RESULT_LIMIT = 1000; + +interface DocSearchProps { + disabled?: boolean; +} + +export const DocSearch: FC = ({ disabled = false }) => { const [visible, setVisible] = useState(false); - const [searchMode, setSearchMode] = useState('files'); + const [searchMode, setSearchMode] = useState('files'); const [query, setQuery] = useState(''); + const [submittedQuery, setSubmittedQuery] = useState(''); const [matchCase, setMatchCase] = useState(false); const [includeFiles, setIncludeFiles] = useState(''); const [excludeFiles, setExcludeFiles] = useState(''); @@ -40,94 +41,137 @@ export const DocSearch: FC = () => { const [fileResults, setFileResults] = useState([]); const [contentResults, setContentResults] = useState([]); const [collapsedFiles, setCollapsedFiles] = useState>(new Set()); + const [hasSearched, setHasSearched] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [documentOnlyContent, setDocumentOnlyContent] = useState(false); + const [searchError, setSearchError] = useState(); + const [incompleteResults, setIncompleteResults] = useState(false); + const [totalCount, setTotalCount] = useState(); + const [nextPage, setNextPage] = useState(); + const [rateLimitReset, setRateLimitReset] = useState(); - const [searchFiles] = useLazySearchFilesQuery(); - const [searchContent] = useLazySearchContentQuery(); + const workspace = useSelector(selectGithubWorkspace); + const searchWorkspace = useWorkspaceSearch(); + const searchAvailability = useGitHubSearchAvailability(); + const requestSequenceRef = useRef(0); const navigate = useNavigate(); - const performSearch = useCallback( - (q: string, mode: SearchMode, mc: boolean, inc: string, exc: string) => { - const trimmed = q.trim(); - if (trimmed === '') { - if (mode === 'files') setFileResults([]); - else setContentResults([]); - return; - } - if (mode === 'files') { - searchFiles(trimmed) - .unwrap() - .then((data) => { - setFileResults(data ?? []); - }) - .catch(() => { - setFileResults([]); - }); - } else { - searchContent({ - q: trimmed, - caseSensitive: mc, - includeFiles: inc.trim() || undefined, - excludeFiles: exc.trim() || undefined, - }) - .unwrap() - .then((data) => { - setContentResults(data ?? []); - }) - .catch(() => { - setContentResults([]); - }) - .finally(() => { - setCollapsedFiles(new Set()); - }); - } - }, - [searchFiles, searchContent, setCollapsedFiles], - ); - - const debouncedSearch = useDebounce(performSearch, DEBOUNCE_MS, [], false); + const resetResults = useCallback(() => { + requestSequenceRef.current += 1; + setFileResults([]); + setContentResults([]); + setCollapsedFiles(new Set()); + setSubmittedQuery(''); + setHasSearched(false); + setIsSearching(false); + setDocumentOnlyContent(false); + setSearchError(undefined); + setIncompleteResults(false); + setTotalCount(undefined); + setNextPage(undefined); + }, []); const handleQueryChange = useCallback( (value: string) => { setQuery(value); - debouncedSearch(value, searchMode, matchCase, includeFiles, excludeFiles); + resetResults(); }, - [searchMode, matchCase, includeFiles, excludeFiles, debouncedSearch], + [resetResults], ); const handleModeChange = useCallback( - (mode: SearchMode) => { + (mode: WorkspaceSearchMode) => { setSearchMode(mode); - performSearch(query, mode, matchCase, includeFiles, excludeFiles); + resetResults(); }, - [query, matchCase, includeFiles, excludeFiles, performSearch], + [resetResults], ); const handleMatchCaseToggle = useCallback(() => { const next = !matchCase; setMatchCase(next); - if (searchMode === 'content') { - performSearch(query, searchMode, next, includeFiles, excludeFiles); - } - }, [matchCase, searchMode, query, includeFiles, excludeFiles, performSearch]); + resetResults(); + }, [matchCase, resetResults]); const handleIncludeChange = useCallback( (value: string) => { setIncludeFiles(value); - if (searchMode === 'content') { - debouncedSearch(query, searchMode, matchCase, value, excludeFiles); - } + resetResults(); }, - [searchMode, query, matchCase, excludeFiles, debouncedSearch], + [resetResults], ); const handleExcludeChange = useCallback( (value: string) => { setExcludeFiles(value); - if (searchMode === 'content') { - debouncedSearch(query, searchMode, matchCase, includeFiles, value); + resetResults(); + }, + [resetResults], + ); + + const performSearch = useCallback( + async (page = 1) => { + const trimmedQuery = query.trim(); + if (!trimmedQuery || isSearching || !searchAvailability.supported) return; + if (rateLimitReset && rateLimitReset > Date.now()) return; + + const requestSequence = requestSequenceRef.current + 1; + requestSequenceRef.current = requestSequence; + setIsSearching(true); + setSearchError(undefined); + try { + const response = await searchWorkspace({ + query: trimmedQuery, + mode: searchMode, + caseSensitive: matchCase, + includeFiles: includeFiles.trim() || undefined, + excludeFiles: excludeFiles.trim() || undefined, + page, + }); + if (requestSequenceRef.current !== requestSequence) return; + + setSubmittedQuery(trimmedQuery); + setHasSearched(true); + setDocumentOnlyContent(response.documentOnlyContent); + setIncompleteResults(response.incompleteResults); + setTotalCount(response.totalCount); + setNextPage(response.nextPage); + setCollapsedFiles(new Set()); + setContentResults(response.contentResults); + setFileResults((previous) => { + if (page === 1 || !response.documentOnlyContent) return response.fileResults; + const merged = new Map(previous.map((item) => [normalizePath(item.path), item])); + response.fileResults.forEach((item) => merged.set(normalizePath(item.path), item)); + return [...merged.values()]; + }); + } catch (error) { + if (requestSequenceRef.current !== requestSequence) return; + const githubError = error as { message?: string; rateLimitReset?: number }; + if (githubError.rateLimitReset && githubError.rateLimitReset > Date.now()) { + setRateLimitReset(githubError.rateLimitReset); + setSearchError( + `GitHub search rate limit reached. Try again after ${new Date( + githubError.rateLimitReset, + ).toLocaleTimeString()}.`, + ); + } else { + setSearchError(githubError.message ?? 'Search failed.'); + } + } finally { + if (requestSequenceRef.current === requestSequence) setIsSearching(false); } }, - [searchMode, query, matchCase, includeFiles, debouncedSearch], + [ + excludeFiles, + includeFiles, + isSearching, + matchCase, + query, + rateLimitReset, + searchAvailability.supported, + searchMode, + searchWorkspace, + ], ); const navigateToDoc = useCallback( @@ -162,13 +206,18 @@ export const DocSearch: FC = () => { }, []); const openSearch = useCallback(() => { + if (disabled) return; setVisible(true); - }, []); + }, [disabled]); const onHide = useCallback(() => { setVisible(false); }, []); + useEffect(() => { + if (disabled) setVisible(false); + }, [disabled]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'k') { @@ -186,12 +235,52 @@ export const DocSearch: FC = () => { }; }, [openSearch, onHide, visible]); - const currentQuery = query.trim(); + useEffect(() => { + resetResults(); + setRateLimitReset(undefined); + }, [resetResults, workspace.config.branch, workspace.config.owner, workspace.config.repo, workspace.mode]); + + useEffect(() => { + if (!rateLimitReset) return; + const remaining = rateLimitReset - Date.now(); + if (remaining <= 0) { + setRateLimitReset(undefined); + return; + } + const timer = window.setTimeout(() => { + setRateLimitReset(undefined); + setSearchError(undefined); + }, remaining); + return () => { + window.clearTimeout(timer); + }; + }, [rateLimitReset]); + + const currentQuery = submittedQuery.trim(); const isFileMode = searchMode === 'files'; + const isGitHubMode = workspace.mode === 'github'; + const isRateLimited = Boolean(rateLimitReset && rateLimitReset > Date.now()); + const displayedSearchError = + searchError ?? + (isRateLimited && rateLimitReset + ? `GitHub search rate limit reached. Try again after ${new Date(rateLimitReset).toLocaleTimeString()}.` + : undefined); + const canSubmit = Boolean(query.trim() && !isSearching && searchAvailability.supported && !isRateLimited); + const resultWarning = [ + incompleteResults ? 'GitHub returned incomplete search results.' : '', + totalCount !== undefined && totalCount > GITHUB_SEARCH_RESULT_LIMIT + ? `GitHub found ${totalCount.toLocaleString()} results, but only the first 1,000 are available. Refine the query to see more.` + : '', + ] + .filter(Boolean) + .join(' '); return ( -
    - {!isFileMode && (
    - - {!isFileMode && ( + {!isGitHubMode && ( )} +
    )}
    @@ -278,11 +381,20 @@ export const DocSearch: FC = () => { void performSearch(nextPage)} onNavigate={navigateToDoc} onToggleCollapse={toggleFileCollapse} onCollapseAll={collapseAll} diff --git a/client/src/components/DocSearch/SearchResults.tsx b/client/src/components/DocSearch/SearchResults.tsx index 603aa21..7176133 100644 --- a/client/src/components/DocSearch/SearchResults.tsx +++ b/client/src/components/DocSearch/SearchResults.tsx @@ -75,6 +75,15 @@ interface SearchResultsProps { fileResults: FileNameMatch[]; contentResults: FileContentMatches[]; collapsedFiles: Set; + documentOnlyContent: boolean; + hasSearched: boolean; + isSearching: boolean; + errorMessage?: string; + noticeMessage?: string; + noticeLoading?: boolean; + warningMessage?: string; + canLoadMore?: boolean; + onLoadMore?: () => void; onNavigate: (path: string[], searchContext?: { query: string; lineContent: string }) => void; onToggleCollapse: (filePath: string) => void; onCollapseAll: () => void; @@ -91,6 +100,15 @@ export const SearchResults: FC = ({ fileResults, contentResults, collapsedFiles, + documentOnlyContent, + hasSearched, + isSearching, + errorMessage, + noticeMessage, + noticeLoading, + warningMessage, + canLoadMore, + onLoadMore, onNavigate, onToggleCollapse, onCollapseAll, @@ -100,12 +118,24 @@ export const SearchResults: FC = ({ () => contentResults.length > 0 && contentResults.every((f) => collapsedFiles.has(normalizePath(f.path))), [contentResults, collapsedFiles], ); + const showFileResults = isFileMode || documentOnlyContent; + const emptyMessage = isFileMode ? 'No files found' : 'No matches found'; return (
    - {isFileMode ? ( + {noticeMessage && ( +
    + {noticeLoading && } + {noticeMessage} +
    + )} + {errorMessage &&
    {errorMessage}
    } + {warningMessage &&
    {warningMessage}
    } + {showFileResults ? ( <> - {currentQuery !== '' && fileResults.length === 0 &&
    No files found
    } + {hasSearched && !isSearching && currentQuery !== '' && fileResults.length === 0 && ( +
    {emptyMessage}
    + )} {fileResults.map((item) => (
    = ({
    ))} + {isSearching && ( +
    + Searching… +
    + )} + {canLoadMore && onLoadMore && ( + + )} ) : ( <> @@ -136,7 +176,9 @@ export const SearchResults: FC = ({
    )} - {currentQuery !== '' && contentResults.length === 0 &&
    No matches found
    } + {hasSearched && !isSearching && currentQuery !== '' && contentResults.length === 0 && ( +
    No matches found
    + )} {contentResults.map((file) => { const fileKey = normalizePath(file.path); const isCollapsed = collapsedFiles.has(fileKey); @@ -183,6 +225,11 @@ export const SearchResults: FC = ({
    ); })} + {isSearching && ( +
    + Searching… +
    + )} )}
    diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index 4bc8e39..64c82a1 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -73,15 +73,17 @@ export default function Header() { return (
    -
    - - +
    + {/* TODO: Re-enable GitHub search when authenticated /search/code responses support CORS, or add a proxy. */} + +
    +
    + +
    +
    { return 'GitHub request failed.'; }; +const DEFAULT_RATE_LIMIT_WAIT_MS = 60_000; + +const getGitHubApiError = (error: unknown): GitHubApiError => { + const requestError = error as { + status?: number; + response?: { headers?: Record }; + }; + const response = requestError.response; + const reset = response?.headers?.['x-ratelimit-reset']; + const remaining = response?.headers?.['x-ratelimit-remaining']; + const retryAfter = response?.headers?.['retry-after']; + const isRateLimited = + requestError.status === 429 || (requestError.status === 403 && (remaining === '0' || retryAfter !== undefined)); + const headerRateLimitReset = retryAfter !== undefined ? Date.now() + Number(retryAfter) * 1000 : Number(reset) * 1000; + const rateLimitReset = Number.isFinite(headerRateLimitReset) + ? headerRateLimitReset + : Date.now() + DEFAULT_RATE_LIMIT_WAIT_MS; + return { + message: getErrorMessage(error), + rateLimitReset: isRateLimited ? rateLimitReset : undefined, + }; +}; + const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); +const quoteSearchValue = (value: string) => `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + +// GitHub's REST endpoint still uses the legacy code-search syntax. Keep simple +// terms and paths bare (for example, `test path:docs`) and quote only values +// that cannot safely be emitted as a single token. +const formatSearchTerm = (value: string) => (/^[a-zA-Z0-9_-]+$/.test(value) ? value : quoteSearchValue(value)); +const formatSearchPath = (value: string) => (/^[a-zA-Z0-9_./-]+$/.test(value) ? value : quoteSearchValue(value)); + +const CODE_SEARCH_PAGE_SIZE = 100; +const CODE_SEARCH_RESULT_LIMIT = 1000; + const requestGitTree = async ( config: Pick, treeSha: string, @@ -337,7 +389,7 @@ export const listAccessibleGitHubRepositories = async (): Promise(), + baseQuery: fakeBaseQuery(), tagTypes: ['Branches', 'Repositories', 'Workspace'], endpoints: (builder) => ({ listGitHubRepositories: builder.query({ @@ -437,6 +489,38 @@ export const githubApi = createApi({ } }, }), + searchGitHubCode: builder.query< + GitHubCodeSearchPage, + { owner: string; repo: string; docsRoot: string; query: string; page?: number } + >({ + queryFn: async ({ owner, repo, docsRoot, query, page = 1 }) => { + try { + const qualifiers = [formatSearchTerm(query.trim()), 'in:file', `repo:${owner}/${repo}`, 'extension:md']; + const normalizedDocsRoot = cleanPath(docsRoot); + if (normalizedDocsRoot) qualifiers.push(`path:${formatSearchPath(normalizedDocsRoot)}`); + // TODO: https://github.com/orgs/community/discussions/206576 + const response = await getOctokit().request('GET /search/code', { + q: qualifiers.join(' '), + page, + per_page: CODE_SEARCH_PAGE_SIZE, + headers: { + accept: 'application/vnd.github+json', + }, + }); + const reachableResults = Math.min(response.data.total_count, CODE_SEARCH_RESULT_LIMIT); + return { + data: { + items: response.data.items.map((item) => ({ name: item.name, path: item.path, sha: item.sha })), + totalCount: response.data.total_count, + incompleteResults: response.data.incomplete_results, + nextPage: page * CODE_SEARCH_PAGE_SIZE < reachableResults ? page + 1 : undefined, + }, + }; + } catch (error) { + return { error: getGitHubApiError(error) }; + } + }, + }), createGitHubRepository: builder.mutation< { config: GitHubWorkspaceConfig; snapshot: GitHubWorkspaceSnapshot }, CreateGitHubRepositoryPayload @@ -515,4 +599,5 @@ export const { useListGitHubRepositoriesQuery, useLoadGitHubWorkspaceQuery, usePublishGitHubWorkspaceMutation, + useLazySearchGitHubCodeQuery, } = githubApi; diff --git a/client/src/utils/hooks/searchHooks.ts b/client/src/utils/hooks/searchHooks.ts new file mode 100644 index 0000000..1b65595 --- /dev/null +++ b/client/src/utils/hooks/searchHooks.ts @@ -0,0 +1,148 @@ +import { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import type { FileContentMatches, FileNameMatch } from '@/redux-api/searchApi'; + +import { useLazySearchGitHubCodeQuery, useListGitHubRepositoriesQuery } from '@/redux-api/github'; +import { useLazySearchContentQuery, useLazySearchFilesQuery } from '@/redux-api/searchApi'; +import { selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; +import { getRepositoryDocPath } from '@/utils/githubWorkspace'; +import { githubWorkspaceStore } from '@/utils/githubWorkspaceRuntime'; +import { useGitHubDirectoryLoader } from '@/utils/hooks/workspaceHooks'; +import { mergeGitHubContentResults, searchGitHubFileNames } from '@/utils/workspaceSearch'; + +export type WorkspaceSearchMode = 'content' | 'files'; + +export interface WorkspaceSearchRequest { + query: string; + mode: WorkspaceSearchMode; + caseSensitive?: boolean; + includeFiles?: string; + excludeFiles?: string; + page?: number; +} + +export interface WorkspaceSearchResponse { + fileResults: FileNameMatch[]; + contentResults: FileContentMatches[]; + documentOnlyContent: boolean; + incompleteResults: boolean; + totalCount?: number; + nextPage?: number; +} + +export const useGitHubSearchAvailability = () => { + const workspace = useSelector(selectGithubWorkspace); + const isGitHubMode = workspace.mode === 'github'; + const repositoryConfigured = Boolean(workspace.config.owner && workspace.config.repo); + const repositoriesQuery = useListGitHubRepositoriesQuery(undefined, { + skip: !isGitHubMode || !repositoryConfigured, + refetchOnMountOrArgChange: true, + }); + const repository = repositoriesQuery.data?.find( + (item) => item.owner === workspace.config.owner && item.name === workspace.config.repo, + ); + + return useMemo(() => { + if (!isGitHubMode) return { supported: true, isChecking: false }; + if (!repositoryConfigured) { + return { supported: false, isChecking: false, reason: 'Select a GitHub repository in Settings to search.' }; + } + if (repositoriesQuery.isFetching || (!repositoriesQuery.isSuccess && !repositoriesQuery.isError)) { + return { supported: false, isChecking: true, reason: 'Checking the repository default branch…' }; + } + if (repositoriesQuery.isError) { + const error = repositoriesQuery.error as { message?: string } | undefined; + return { + supported: false, + isChecking: false, + reason: error?.message ?? 'Could not determine the repository default branch.', + }; + } + if (!repository) { + return { + supported: false, + isChecking: false, + reason: `The GitHub App cannot access ${workspace.config.owner}/${workspace.config.repo}.`, + }; + } + if (workspace.config.branch !== repository.defaultBranch) { + return { + supported: false, + isChecking: false, + defaultBranch: repository.defaultBranch, + reason: `GitHub search only supports the default branch “${repository.defaultBranch}”. The current branch is “${workspace.config.branch}”.`, + }; + } + return { supported: true, isChecking: false, defaultBranch: repository.defaultBranch }; + }, [isGitHubMode, repository, repositoryConfigured, repositoriesQuery, workspace.config]); +}; + +export const useWorkspaceSearch = () => { + const workspace = useSelector(selectGithubWorkspace); + const [searchLocalFiles] = useLazySearchFilesQuery(); + const [searchLocalContent] = useLazySearchContentQuery(); + const [searchGitHubCode] = useLazySearchGitHubCodeQuery(); + const { loadDirectoryTree } = useGitHubDirectoryLoader(); + + return useCallback( + async ({ query, mode, caseSensitive, includeFiles, excludeFiles, page = 1 }: WorkspaceSearchRequest) => { + if (workspace.mode === 'local') { + if (mode === 'files') { + return { + fileResults: (await searchLocalFiles(query).unwrap()) ?? [], + contentResults: [], + documentOnlyContent: false, + incompleteResults: false, + } satisfies WorkspaceSearchResponse; + } + return { + fileResults: [], + contentResults: + (await searchLocalContent({ + q: query, + caseSensitive, + includeFiles, + excludeFiles, + }).unwrap()) ?? [], + documentOnlyContent: false, + incompleteResults: false, + } satisfies WorkspaceSearchResponse; + } + + if (mode === 'files') { + await loadDirectoryTree(getRepositoryDocPath(workspace.config, '', false)); + return { + fileResults: searchGitHubFileNames(workspace.config, githubWorkspaceStore.getSnapshot().entries, query), + contentResults: [], + documentOnlyContent: false, + incompleteResults: false, + } satisfies WorkspaceSearchResponse; + } + + const remotePage = await searchGitHubCode({ + owner: workspace.config.owner, + repo: workspace.config.repo, + docsRoot: workspace.config.docsRoot, + query, + page, + }).unwrap(); + return { + fileResults: mergeGitHubContentResults( + workspace.config, + githubWorkspaceStore.getSnapshot(), + remotePage.items, + query, + includeFiles, + excludeFiles, + ), + contentResults: [], + documentOnlyContent: true, + incompleteResults: remotePage.incompleteResults, + totalCount: remotePage.totalCount, + nextPage: remotePage.nextPage, + } satisfies WorkspaceSearchResponse; + }, + [loadDirectoryTree, searchGitHubCode, searchLocalContent, searchLocalFiles, workspace], + ); +}; diff --git a/client/src/utils/hooks/workspaceHooks.ts b/client/src/utils/hooks/workspaceHooks.ts index 1cf4173..d340ec5 100644 --- a/client/src/utils/hooks/workspaceHooks.ts +++ b/client/src/utils/hooks/workspaceHooks.ts @@ -70,7 +70,7 @@ const listGitHubSubItems = (config: GitHubWorkspaceConfig, logicalFolderPath = ' const getParentRepositoryPath = (path: string) => path.split('/').filter(Boolean).slice(0, -1).join('/'); -const useGitHubDirectoryLoader = () => { +export const useGitHubDirectoryLoader = () => { const workspace = useSelector(selectGithubWorkspace); const [getDirectoryPage] = useLazyGetGitHubDirectoryQuery(); diff --git a/client/src/utils/workspaceSearch.ts b/client/src/utils/workspaceSearch.ts new file mode 100644 index 0000000..fc8632c --- /dev/null +++ b/client/src/utils/workspaceSearch.ts @@ -0,0 +1,140 @@ +import type { GitHubCodeSearchItem } from '@/redux-api/github'; +import type { FileNameMatch } from '@/redux-api/searchApi'; +import type { GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import type { WorkspaceEntry, WorkspaceSnapshot } from '@markdown-editor/github-workspace'; + +const MARKDOWN_EXTENSION = '.md'; + +const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); + +const getRelativeRepositoryPath = (config: GitHubWorkspaceConfig, repositoryPath: string) => { + const root = cleanPath(config.docsRoot); + if (!root) return cleanPath(repositoryPath); + if (!repositoryPath.startsWith(`${root}/`)) return null; + return repositoryPath.slice(root.length + 1); +}; + +const isVisibleMarkdownPath = (config: GitHubWorkspaceConfig, repositoryPath: string) => { + const relativePath = getRelativeRepositoryPath(config, repositoryPath); + if (relativePath === null || !relativePath.endsWith(MARKDOWN_EXTENSION)) return false; + const parts = relativePath.split('/').filter(Boolean); + return ( + parts.length > 0 && + !parts.some((part) => part.startsWith('.')) && + !parts.slice(0, -1).some((part) => part === '_assets' || config.ignoreDirs.includes(part)) + ); +}; + +const toFileNameMatch = (config: GitHubWorkspaceConfig, repositoryPath: string): FileNameMatch => { + const relativePath = getRelativeRepositoryPath(config, repositoryPath) ?? repositoryPath; + const path = relativePath.slice(0, -MARKDOWN_EXTENSION.length).split('/'); + return { name: path.at(-1) ?? '', path }; +}; + +const getFileMatchKey = (match: FileNameMatch) => JSON.stringify(match.path); + +const sortFileMatches = (matches: FileNameMatch[], query: string) => { + const normalizedQuery = query.toLocaleLowerCase(); + return matches.sort((left, right) => { + const leftName = left.name.toLocaleLowerCase(); + const rightName = right.name.toLocaleLowerCase(); + const leftStartsWith = leftName.startsWith(normalizedQuery); + const rightStartsWith = rightName.startsWith(normalizedQuery); + if (leftStartsWith !== rightStartsWith) return leftStartsWith ? -1 : 1; + return leftName.localeCompare(rightName); + }); +}; + +const getVisibleMarkdownEntries = (config: GitHubWorkspaceConfig, entries: Record) => + Object.values(entries).filter((entry) => entry.kind === 'file' && isVisibleMarkdownPath(config, entry.path)); + +export const searchGitHubFileNames = ( + config: GitHubWorkspaceConfig, + entries: Record, + query: string, +) => { + const normalizedQuery = query.toLocaleLowerCase(); + return sortFileMatches( + getVisibleMarkdownEntries(config, entries) + .map((entry) => toFileNameMatch(config, entry.path)) + .filter((entry) => entry.name.toLocaleLowerCase().includes(normalizedQuery)), + query, + ); +}; + +const parsePatterns = (value?: string) => + (value ?? '') + .split(',') + .map((pattern) => pattern.trim().toLocaleLowerCase()) + .filter(Boolean); + +const matchesPathFilters = ( + config: GitHubWorkspaceConfig, + repositoryPath: string, + includeFiles?: string, + excludeFiles?: string, +) => { + const path = (getRelativeRepositoryPath(config, repositoryPath) ?? repositoryPath).toLocaleLowerCase(); + const includes = parsePatterns(includeFiles); + const excludes = parsePatterns(excludeFiles); + return ( + (includes.length === 0 || includes.some((pattern) => path.includes(pattern))) && + !excludes.some((pattern) => path.includes(pattern)) + ); +}; + +const contentIncludes = (content: string, query: string) => + content.toLocaleLowerCase().includes(query.toLocaleLowerCase()); + +export const mergeGitHubContentResults = ( + config: GitHubWorkspaceConfig, + snapshot: WorkspaceSnapshot, + remoteItems: GitHubCodeSearchItem[], + query: string, + includeFiles?: string, + excludeFiles?: string, +): FileNameMatch[] => { + const visibleEntries = getVisibleMarkdownEntries(config, snapshot.entries); + const entriesBySha = new Map(); + visibleEntries.forEach((entry) => { + if (!entry.sha) return; + const entries = entriesBySha.get(entry.sha) ?? []; + entries.push(entry); + entriesBySha.set(entry.sha, entries); + }); + + const deletedPaths = new Set( + [...snapshot.stagedChanges, ...snapshot.unstagedChanges] + .filter((change) => change.newEntry === null) + .map((change) => change.oldEntry?.path ?? change.path), + ); + const results = new Map(); + const addEntry = (entry: WorkspaceEntry) => { + if (!matchesPathFilters(config, entry.path, includeFiles, excludeFiles)) return; + const result = toFileNameMatch(config, entry.path); + results.set(getFileMatchKey(result), result); + }; + + remoteItems.forEach((item) => { + const currentEntries = entriesBySha.get(item.sha); + if (currentEntries?.length) { + currentEntries.forEach((entry) => { + if (entry.content !== undefined && !contentIncludes(entry.content, query)) return; + addEntry(entry); + }); + return; + } + if (!isVisibleMarkdownPath(config, item.path) || deletedPaths.has(item.path)) return; + if (!matchesPathFilters(config, item.path, includeFiles, excludeFiles)) return; + const result = toFileNameMatch(config, item.path); + results.set(getFileMatchKey(result), result); + }); + + visibleEntries.forEach((entry) => { + if (entry.content !== undefined && contentIncludes(entry.content, query)) addEntry(entry); + }); + + return [...results.values()].sort((left, right) => + left.name.toLocaleLowerCase().localeCompare(right.name.toLocaleLowerCase()), + ); +}; From 1e8aa84361b267eb04bf5dacdb1840dae92c213d Mon Sep 17 00:00:00 2001 From: s-elo Date: Tue, 15 Sep 2026 11:26:24 +0800 Subject: [PATCH 16/17] fix: container block copy paste issue --- .../src/plugins/plugin-container/index.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/editor-core/src/plugins/plugin-container/index.ts b/packages/editor-core/src/plugins/plugin-container/index.ts index 210c88d..b0f1b5a 100644 --- a/packages/editor-core/src/plugins/plugin-container/index.ts +++ b/packages/editor-core/src/plugins/plugin-container/index.ts @@ -23,9 +23,19 @@ export const containerSchema = $nodeSchema('container', () => ({ }, desc: { default: '', validate: 'string' }, }, + // ProseMirror uses the schema's DOM serializer for clipboard operations instead of the custom node view. + toDOM: (node) => [ + 'div', + { + 'data-container-type': node.attrs.containerType, + 'data-description': node.attrs.desc, + }, + 0, + ], parseDOM: [ { - tag: 'div', + // Keep this selector specific so nested node-view divs are not parsed as additional containers on paste. + tag: 'div[data-container-type]', getAttrs: (dom) => ({ containerType: dom.getAttribute('data-container-type') ?? ContainerType.TIP, desc: dom.getAttribute('data-description') ?? '', From 2b3fd3b99ed090425fd24c2c0281006aa827fe81 Mon Sep 17 00:00:00 2001 From: s-elo Date: Thu, 17 Sep 2026 18:57:53 +0800 Subject: [PATCH 17/17] feat: support image management for github mode --- .../components/Editor/configs/uploadConfig.ts | 27 ++- client/src/components/Header/Header.tsx | 27 ++- .../ImgManagement/ImgManagement.scss | 14 +- .../ImgManagement/ImgManagement.tsx | 172 +++++++++++--- client/src/constants.ts | 38 ++- client/src/redux-api/github.ts | 34 ++- client/src/redux-api/img.ts | 2 +- .../src/redux-feature/githubWorkspaceSlice.ts | 14 +- client/src/utils/githubImages.ts | 219 ++++++++++++++++++ .../editor-core/src/configs/uploadConfig.ts | 2 +- packages/editor-core/src/constants.ts | 17 ++ packages/editor-core/src/crepe.ts | 3 +- packages/editor-core/src/imageResolution.ts | 52 +++++ packages/editor-core/src/index.ts | 1 + packages/editor-core/src/mountedAddons.tsx | 81 ++++++- .../src/plugins/plugin-highlight/index.ts | 3 +- packages/editor-core/src/styles/editor.scss | 66 ++++++ packages/github-workspace/src/git-status.ts | 4 +- packages/github-workspace/src/index.ts | 18 +- packages/github-workspace/src/types.ts | 3 + .../src/workspace-operations.ts | 13 +- 21 files changed, 735 insertions(+), 75 deletions(-) create mode 100644 client/src/utils/githubImages.ts create mode 100644 packages/editor-core/src/constants.ts create mode 100644 packages/editor-core/src/imageResolution.ts diff --git a/client/src/components/Editor/configs/uploadConfig.ts b/client/src/components/Editor/configs/uploadConfig.ts index 664dd7b..b9d081d 100644 --- a/client/src/components/Editor/configs/uploadConfig.ts +++ b/client/src/components/Editor/configs/uploadConfig.ts @@ -1,6 +1,25 @@ -import { SERVER_BASE_URL } from '@/constants'; +import { GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, SERVER_BASE_URL, WORKSPACE_MODE_STORAGE_KEY } from '@/constants'; +import { emptyConfig, type GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import { clearGitHubImageUrlCache, resolveGitHubImageUrl, uploadGitHubImage } from '@/utils/githubImages'; -export function getImageUrl(url: string) { +const getWorkspaceMode = () => window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY); + +const getGitHubConfig = (): GitHubWorkspaceConfig => { + try { + return { + ...emptyConfig, + ...(JSON.parse( + window.localStorage.getItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY) ?? '{}', + ) as Partial), + }; + } catch { + return emptyConfig; + } +}; + +export function getImageUrl(url: string): Promise | string { + if (getWorkspaceMode() === 'github') return resolveGitHubImageUrl(getGitHubConfig(), url); + clearGitHubImageUrlCache(); if (url.startsWith('/')) { return `${SERVER_BASE_URL}/imgs${url}`; } @@ -8,9 +27,7 @@ export function getImageUrl(url: string) { } export async function uploadImage(file: File) { - if (window.localStorage.getItem('workspace-mode') === 'github') { - throw new Error('Image uploads are not available in GitHub workspace mode yet.'); - } + if (getWorkspaceMode() === 'github') return uploadGitHubImage(getGitHubConfig(), file); const formData = new FormData(); formData.append('file', file); const res = await fetch(`${SERVER_BASE_URL}/imgs/upload`, { diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx index 64c82a1..f56ada6 100644 --- a/client/src/components/Header/Header.tsx +++ b/client/src/components/Header/Header.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Menu } from 'primereact/menu'; import { MenuItem } from 'primereact/menuitem'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { DocSearch } from '../DocSearch/DocSearch'; @@ -9,8 +9,9 @@ import { ImgManagement } from '../ImgManagement/ImgManagement'; import { Icon } from '@/components/Icon/Icon'; import { selectCurDoc } from '@/redux-feature/curDocSlice'; -import { selectWorkspaceMode } from '@/redux-feature/githubWorkspaceSlice'; +import { selectGithubWorkspace } from '@/redux-feature/githubWorkspaceSlice'; import { selectGlobalOpts } from '@/redux-feature/globalOptsSlice'; +import { activateGitHubImageWorkspace } from '@/utils/githubImages'; import { useGitHubLogin } from '@/utils/hooks/githubAuthHooks'; import { useSaveDoc, useSwitchReadonlyMode, useSwitchTheme } from '@/utils/hooks/reduxHooks'; import { nextTick } from '@/utils/utils'; @@ -20,7 +21,8 @@ import './Header.scss'; // eslint-disable-next-line @typescript-eslint/naming-convention export default function Header() { const { readonly, theme } = useSelector(selectGlobalOpts); - const workspaceMode = useSelector(selectWorkspaceMode); + const workspace = useSelector(selectGithubWorkspace); + const workspaceMode = workspace.mode; const { isDirty, type, contentIdent } = useSelector(selectCurDoc); const themeMenuRef = useRef(null); @@ -30,6 +32,16 @@ export default function Header() { const switchReadonlyMode = useSwitchReadonlyMode(); const switchTheme = useSwitchTheme(); + useEffect(() => { + activateGitHubImageWorkspace(workspaceMode === 'github' ? workspace.config : undefined); + }, [ + workspace.config.branch, + workspace.config.docsRoot, + workspace.config.owner, + workspace.config.repo, + workspaceMode, + ]); + const themeMenuItems: MenuItem[] = [ { label: 'Themes', @@ -76,14 +88,7 @@ export default function Header() {
    {/* TODO: Re-enable GitHub search when authenticated /search/code responses support CORS, or add a proxy. */} -
    -
    - -
    -
    +
    { const MS_PER_SECOND = 1000; -function formatDate(timestamp: number): string { +function formatDate(timestamp?: number): string { if (!timestamp) return '-'; return new Date(timestamp * MS_PER_SECOND).toLocaleDateString(undefined, { year: 'numeric', @@ -55,12 +65,50 @@ function formatDate(timestamp: number): string { }); } +/** + * Resolves previews through the same mode-aware path used by the editor. + * In GitHub mode, remote SHAs use an authenticated blob request while new + * working-tree images render directly from their staged base64 content. + */ +const WorkspaceImage: FC<{ image: ImgListItem }> = ({ image }) => { + const [src, setSrc] = useState(); + const [error, setError] = useState(false); + + useEffect(() => { + let active = true; + setSrc(undefined); + setError(false); + void Promise.resolve(getImageUrl(image.url)).then( + (resolvedUrl) => { + if (active) setSrc(resolvedUrl); + }, + () => { + if (active) setError(true); + }, + ); + return () => { + active = false; + }; + }, [image.url]); + + if (error) return
    Preview unavailable
    ; + if (!src) return
    Loading...
    ; + return {image.fileName}; +}; + export const ImgManagement: FC = () => { const [showImgManagementModal, setShowImgManagementModal] = useState(false); const [filterMode, setFilterMode] = useState('used'); + const [githubListLoading, setGithubListLoading] = useState(false); + const [githubListError, setGithubListError] = useState(); + const workspace = useSelector(selectGithubWorkspace); + const githubSnapshot = useGitHubWorkspaceSnapshot(); + const isGitHubMode = workspace.mode === 'github'; - const { data: images = [], refetch: refetchImgList } = useGetImgListQuery(undefined, { - skip: !showImgManagementModal, + // Local mode continues to use the image server. GitHub mode deliberately + // skips this API because its image inventory comes from the workspace tree. + const { data: localImages = [], refetch: refetchImgList } = useGetImgListQuery(undefined, { + skip: !showImgManagementModal || isGitHubMode, }); const [deleteImg] = useDeleteWorkspaceImgMutation(); const [fetchRefDocs] = useLazyGetImgRefDocsQuery(); @@ -70,6 +118,24 @@ export const ImgManagement: FC = () => { const curContent = useSelector(selectCurContent); const navigate = useNavigate(); const usedUrls = useMemo(() => extractImageUrls(curContent || ''), [curContent]); + const githubAssetsPath = getGitHubAssetsPath(workspace.config); + // listDirectory reads the effective working tree, so this list immediately + // includes unstaged/staged uploads and excludes pending deletions. DataView + // paginates this in-memory list below; no GitHub request is made per page. + const githubImages = useMemo( + () => + githubWorkspaceStore + .listDirectory(githubAssetsPath) + .filter((entry) => entry.kind === 'file' && isSupportedImagePath(entry.path)) + .map((entry) => { + const fileName = entry.path.split('/').at(-1) ?? entry.path; + return { fileName, url: getGitHubImageUrl(workspace.config, fileName) }; + }) + .sort((left, right) => left.fileName.localeCompare(right.fileName)), + [githubAssetsPath, githubSnapshot.entries], + ); + const images = isGitHubMode ? githubImages : localImages; + const githubWorkspaceKey = getGitHubWorkspaceKey(workspace.config); const handleDelete = useCallback( async (fileName: string) => { @@ -77,9 +143,18 @@ export const ImgManagement: FC = () => { message: `Are you sure you want to delete "${fileName}"?`, }); if (!confirmed) return; - await deleteImg(fileName); + try { + if (isGitHubMode) { + await githubWorkspaceStore.delete(`${githubAssetsPath}/${fileName}`); + removeGitHubImageUrl(workspace.config, getGitHubImageUrl(workspace.config, fileName)); + } else { + await deleteImg(fileName).unwrap(); + } + } catch (error) { + Toast.error((error as Error).message ?? 'Failed to delete image.'); + } }, - [deleteImg], + [deleteImg, githubAssetsPath, isGitHubMode, workspace.config], ); const handleFindRefs = useCallback( @@ -105,41 +180,82 @@ export const ImgManagement: FC = () => { const filteredImages = useMemo(() => { if (filterMode === 'all') return images; return images.filter((img) => { - const isUsed = usedUrls.has(img.url); + const isUsed = + usedUrls.has(img.url) || (isGitHubMode && usedUrls.has(`${WORKSPACE_ASSET_SEGMENT}${img.fileName}`)); return filterMode === 'used' ? isUsed : !isUsed; }); - }, [images, filterMode, usedUrls]); + }, [filterMode, images, isGitHubMode, usedUrls]); useEffect(() => { if (!showImgManagementModal) return; - refetchImgList(); - }, [refetchImgList, usedUrls]); + if (!isGitHubMode) { + void refetchImgList(); + return; + } + let active = true; + setGithubListLoading(true); + setGithubListError(undefined); + // Large repositories may initialize with only the docs root's direct + // children. Fetch the _assets Git tree once on demand, then merge it into + // the workspace without overwriting staged or unstaged changes. + void loadGitHubAssetsDirectory(workspace.config).then( + () => { + if (active) setGithubListLoading(false); + }, + (error: unknown) => { + if (!active) return; + setGithubListLoading(false); + setGithubListError((error as Error).message ?? 'Failed to load images.'); + }, + ); + return () => { + active = false; + }; + }, [ + isGitHubMode, + githubSnapshot.baseCommitSha, + githubSnapshot.persistenceLoaded, + refetchImgList, + showImgManagementModal, + usedUrls, + workspace.config.branch, + workspace.config.docsRoot, + workspace.config.owner, + workspace.config.repo, + ]); + + useEffect(() => { + if (isGitHubMode) setRefDocsFor(null); + }, [isGitHubMode]); const itemTemplate = (img: ImgListItem, index: number) => { - const isUsed = usedUrls.has(img.url); + const isUsed = usedUrls.has(img.url) || (isGitHubMode && usedUrls.has(`${WORKSPACE_ASSET_SEGMENT}${img.fileName}`)); + const itemKey = isGitHubMode ? `${githubWorkspaceKey}:${img.url}` : img.url; return ( -
    +
    - {img.fileName} +
    {img.fileName}
    - {formatDate(img.createdTime)} + {!isGitHubMode && {formatDate(img.createdTime)}}
    -
    - {refDocsFor === img.fileName && ( + {!isGitHubMode && refDocsFor === img.fileName && (
    {refDocsLoading ? ( Loading... @@ -204,7 +320,10 @@ export const ImgManagement: FC = () => { }; const listTemplate = (items: ImgListItem[]) => { - if (!items || items.length === 0) return
    Empty
    ; + if (!items || items.length === 0) { + const message = githubListLoading ? 'Loading...' : githubListError ?? 'Empty'; + return
    {message}
    ; + } return
    {items.map((img, i) => itemTemplate(img, i))}
    ; }; @@ -229,6 +348,7 @@ export const ImgManagement: FC = () => { id="img-management-icon" toolTipContent="Images" onClick={() => { + if (isGitHubMode) setGithubListLoading(true); setShowImgManagementModal(true); }} /> diff --git a/client/src/constants.ts b/client/src/constants.ts index 20b746d..95b616a 100644 --- a/client/src/constants.ts +++ b/client/src/constants.ts @@ -1,15 +1,49 @@ +/** Application version injected by the build configuration. */ export const APP_VERSION = __VERSION__; -/** /repo-name/ or / */ + +/** GitHub Pages base path, such as `/repo-name/` or `/`. */ export const GITHUB_PAGES_BASE_PATH = __GITHUB_PAGES_BASE_PATH__; -/** 3024 or 7024*/ + +/** Local server port injected by the build; falsy for the hosted online build. */ export const SERVER_PORT = __SERVER_PORT__; +/** Whether the client is connected to the bundled local server. */ export const ONLINE_MODE = Boolean(SERVER_PORT); +/** Base URL used for requests to the local Markdown Editor API. */ export const SERVER_BASE_URL = ONLINE_MODE ? `http://127.0.0.1:${SERVER_PORT}/api` : '/api'; +/** Directories excluded from GitHub workspace document discovery by default. */ export const DEFAULT_IGNORE_DIRS = ['imgs', 'node_modules', 'dist']; +/** IndexedDB database containing persisted GitHub workspace state. */ export const GITHUB_WORKSPACE_DATABASE_NAME = 'markdown-editor-github-workspaces'; + +/** IndexedDB object store containing version-one GitHub workspace records. */ export const GITHUB_WORKSPACE_STORE_NAME = 'workspaces-v1'; + +/** Repository-relative file used to persist workspace settings. */ export const WORKSPACE_SETTINGS_PATH = '.workspace-settings.json'; + +/** Local-storage key containing the active workspace mode. */ +export const WORKSPACE_MODE_STORAGE_KEY = 'workspace-mode'; + +/** Local-storage key containing the selected GitHub workspace configuration. */ +export const GITHUB_WORKSPACE_CONFIG_STORAGE_KEY = 'github-workspace-config'; + +/** Maximum size GitHub accepts for a regular repository file: 100 MiB. */ +// eslint-disable-next-line @typescript-eslint/no-magic-numbers +export const GITHUB_MAX_REPOSITORY_FILE_SIZE_BYTES = 100 * 1024 * 1024; + +/** Image file extensions supported by GitHub workspace upload and management. */ +export const GITHUB_SUPPORTED_IMAGE_EXTENSIONS: ReadonlySet = new Set([ + 'avif', + 'bmp', + 'gif', + 'ico', + 'jpeg', + 'jpg', + 'png', + 'svg', + 'webp', +]); diff --git a/client/src/redux-api/github.ts b/client/src/redux-api/github.ts index 70b452b..33c9050 100644 --- a/client/src/redux-api/github.ts +++ b/client/src/redux-api/github.ts @@ -268,7 +268,7 @@ const commitTreeToBranch = async ({ baseCommitSha, baseTreeSha, config, entries, owner: config.owner, repo: config.repo, content: entry.content, - encoding: 'utf-8', + encoding: entry.contentEncoding ?? 'utf-8', }); return { path: entry.path, mode: entry.mode, type: entry.type, sha: blob.data.sha }; }), @@ -362,6 +362,29 @@ const decodeBase64Utf8 = (content: string) => { return new TextDecoder().decode(bytes); }; +const encodeUtf8Base64 = (content: string) => { + const bytes = new TextEncoder().encode(content); + let binary = ''; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return window.btoa(binary); +}; + +export const getGitHubBlobBase64 = async (owner: string, repo: string, sha: string) => { + const response = await getOctokit().request('GET /repos/{owner}/{repo}/git/blobs/{file_sha}', { + owner, + repo, + file_sha: sha, + }); + return response.data.encoding === 'base64' + ? response.data.content.replace(/\n/g, '') + : encodeUtf8Base64(response.data.content); +}; + +export const loadGitHubDirectory = async (request: GitHubDirectoryRequest) => getDirectoryPage(request); + export const listAccessibleGitHubRepositories = async (): Promise => { const octokit = getOctokit(); const installations = await octokit.paginate('GET /user/installations', { per_page: 100 }); @@ -476,14 +499,7 @@ export const githubApi = createApi({ getGitHubBlob: builder.query({ queryFn: async ({ owner, repo, sha }) => { try { - const response = await getOctokit().request('GET /repos/{owner}/{repo}/git/blobs/{file_sha}', { - owner, - repo, - file_sha: sha, - }); - return { - data: response.data.encoding === 'base64' ? decodeBase64Utf8(response.data.content) : response.data.content, - }; + return { data: decodeBase64Utf8(await getGitHubBlobBase64(owner, repo, sha)) }; } catch (error) { return { error: { message: getErrorMessage(error) } }; } diff --git a/client/src/redux-api/img.ts b/client/src/redux-api/img.ts index 81e71d3..eeff727 100644 --- a/client/src/redux-api/img.ts +++ b/client/src/redux-api/img.ts @@ -4,7 +4,7 @@ import { transformResponse, transformErrorResponse } from './interceptor'; export interface ImgListItem { fileName: string; url: string; - createdTime: number; + createdTime?: number; } export interface ImgDataType { diff --git a/client/src/redux-feature/githubWorkspaceSlice.ts b/client/src/redux-feature/githubWorkspaceSlice.ts index 89ac9c4..474c1fe 100644 --- a/client/src/redux-feature/githubWorkspaceSlice.ts +++ b/client/src/redux-feature/githubWorkspaceSlice.ts @@ -3,7 +3,7 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import type { RootState } from '@/store'; import type { WorkspaceDescriptor } from '@markdown-editor/github-workspace'; -import { DEFAULT_IGNORE_DIRS } from '@/constants'; +import { DEFAULT_IGNORE_DIRS, GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, WORKSPACE_MODE_STORAGE_KEY } from '@/constants'; export type WorkspaceMode = 'github' | 'local'; export interface GitHubWorkspaceConfig extends WorkspaceDescriptor { @@ -23,14 +23,12 @@ export const emptyConfig: GitHubWorkspaceConfig = { ignoreDirs: [...DEFAULT_IGNORE_DIRS], }; -const MODE_STORAGE_KEY = 'workspace-mode'; -const CONFIG_STORAGE_KEY = 'github-workspace-config'; - -const readMode = (): WorkspaceMode => (window.localStorage.getItem(MODE_STORAGE_KEY) === 'github' ? 'github' : 'local'); +const readMode = (): WorkspaceMode => + window.localStorage.getItem(WORKSPACE_MODE_STORAGE_KEY) === 'github' ? 'github' : 'local'; const readConfig = (): GitHubWorkspaceConfig => { try { - const storedConfig = window.localStorage.getItem(CONFIG_STORAGE_KEY); + const storedConfig = window.localStorage.getItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY); if (!storedConfig) return emptyConfig; const parsed = JSON.parse(storedConfig) as Partial; return { @@ -61,11 +59,11 @@ const githubWorkspaceSlice = createSlice({ reducers: { setWorkspaceMode: (state, action: PayloadAction) => { state.mode = action.payload; - window.localStorage.setItem(MODE_STORAGE_KEY, action.payload); + window.localStorage.setItem(WORKSPACE_MODE_STORAGE_KEY, action.payload); }, setGitHubWorkspaceConfig: (state, action: PayloadAction) => { state.config = action.payload; - window.localStorage.setItem(CONFIG_STORAGE_KEY, JSON.stringify(action.payload)); + window.localStorage.setItem(GITHUB_WORKSPACE_CONFIG_STORAGE_KEY, JSON.stringify(action.payload)); }, }, }); diff --git a/client/src/utils/githubImages.ts b/client/src/utils/githubImages.ts new file mode 100644 index 0000000..cc7bceb --- /dev/null +++ b/client/src/utils/githubImages.ts @@ -0,0 +1,219 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { WORKSPACE_ASSET_DIRECTORY } from '@markdown-editor/core'; + +import { GITHUB_MAX_REPOSITORY_FILE_SIZE_BYTES, GITHUB_SUPPORTED_IMAGE_EXTENSIONS } from '@/constants'; +import { getGitHubBlobBase64, loadGitHubDirectory } from '@/redux-api/github'; +import { getGitHubWorkspaceKey, type GitHubWorkspaceConfig } from '@/redux-feature/githubWorkspaceSlice'; +import { githubWorkspaceStore } from '@/utils/githubWorkspaceRuntime'; + +interface CachedImageUrl { + objectUrl: string; + sourceKey: string; + workspaceKey: string; +} + +type WorkspaceImageSource = + | { content: string; kind: 'local'; sourceKey: string } + | { kind: 'remote'; sha: string; sourceKey: string }; + +const imageUrlCache = new Map(); +let activeWorkspaceKey = ''; + +const cleanPath = (path: string) => path.replace(/^\/+|\/+$/g, ''); + +export const getGitHubAssetsPath = (config: GitHubWorkspaceConfig) => + [cleanPath(config.docsRoot), WORKSPACE_ASSET_DIRECTORY].filter(Boolean).join('/'); + +/** Returns the repository-root-relative URL written into GitHub-mode Markdown. */ +export const getGitHubImageUrl = (config: GitHubWorkspaceConfig, fileName: string) => + `/${[getGitHubAssetsPath(config), cleanPath(fileName)].filter(Boolean).join('/')}`; + +const getGitHubImageRepositoryPath = (config: GitHubWorkspaceConfig, url: string) => { + if (!url.startsWith('/')) return undefined; + const normalizedUrl = cleanPath(url); + const assetsPath = getGitHubAssetsPath(config); + const legacyAssetsPath = `${WORKSPACE_ASSET_DIRECTORY}/`; + const repositoryPath = normalizedUrl.startsWith(`${assetsPath}/`) + ? normalizedUrl + : normalizedUrl.startsWith(legacyAssetsPath) + ? [cleanPath(config.docsRoot), normalizedUrl].filter(Boolean).join('/') + : undefined; + if (!repositoryPath) return undefined; + + const imagePath = repositoryPath.slice(assetsPath.length + 1); + if (!imagePath || imagePath.includes('/')) throw new Error(`Unsupported image path: ${url}`); + return repositoryPath; +}; + +export const isSupportedImagePath = (path: string) => { + const extension = path.split('.').at(-1)?.toLocaleLowerCase() ?? ''; + return GITHUB_SUPPORTED_IMAGE_EXTENSIONS.has(extension); +}; + +const getImageMime = (path: string) => { + const extension = path.split('.').at(-1)?.toLocaleLowerCase(); + switch (extension) { + case 'avif': + return 'image/avif'; + case 'bmp': + return 'image/bmp'; + case 'gif': + return 'image/gif'; + case 'ico': + return 'image/x-icon'; + case 'jpeg': + case 'jpg': + return 'image/jpeg'; + case 'svg': + return 'image/svg+xml'; + case 'webp': + return 'image/webp'; + default: + return 'image/png'; + } +}; + +const base64ToBlob = (content: string, mime: string) => { + const binary = window.atob(content.replace(/\n/g, '')); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return new Blob([bytes], { type: mime }); +}; + +const fileToBase64 = async (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => { + reject(reader.error ?? new Error('Failed to read image.')); + }; + reader.onload = () => { + const result = reader.result; + if (typeof result !== 'string') { + reject(new Error('Failed to encode image.')); + return; + } + resolve(result.slice(result.indexOf(',') + 1)); + }; + reader.readAsDataURL(file); + }); + +const hasBytes = (bytes: Uint8Array, expected: number[]) => expected.every((value, index) => bytes[index] === value); + +const inferExtensionFromBytes = (bytes: Uint8Array) => { + if (bytes.length >= 8 && hasBytes(bytes, [137, 80, 78, 71, 13, 10, 26, 10])) return 'png'; + if (bytes.length >= 3 && hasBytes(bytes, [0xff, 0xd8, 0xff])) return 'jpg'; + const header = new TextDecoder().decode(bytes.subarray(0, 12)); + if (header.startsWith('GIF87a') || header.startsWith('GIF89a')) return 'gif'; + if (header.startsWith('RIFF') && header.slice(8, 12) === 'WEBP') return 'webp'; + if (header.startsWith(' { + const extension = file.name.split('.').at(-1)?.toLocaleLowerCase(); + if (extension && GITHUB_SUPPORTED_IMAGE_EXTENSIONS.has(extension)) return extension === 'jpeg' ? 'jpg' : extension; + return inferExtensionFromBytes(bytes); +}; + +const getContentHash = async (bytes: Uint8Array) => { + const digest = new Uint8Array(await window.crypto.subtle.digest('SHA-256', Uint8Array.from(bytes).buffer)); + return [...digest.subarray(0, 8)].map((value) => value.toString(16).padStart(2, '0')).join(''); +}; + +export const loadGitHubAssetsDirectory = async (config: GitHubWorkspaceConfig) => { + const assetsPath = getGitHubAssetsPath(config); + const request = githubWorkspaceStore.getDirectoryLoadRequest(assetsPath); + if (!request) return; + const page = await loadGitHubDirectory({ owner: config.owner, repo: config.repo, ...request }); + await githubWorkspaceStore.attachRemoteDirectory(page); +}; + +const getWorkspaceImageSource = async (config: GitHubWorkspaceConfig, repositoryPath: string) => { + await loadGitHubAssetsDirectory(config); + const source = githubWorkspaceStore.getFileSource(repositoryPath); + if (!source) throw new Error(`Image not found: ${repositoryPath}`); + if (source.kind === 'local') { + if (source.contentEncoding !== 'base64') throw new Error(`Image content is not binary: ${repositoryPath}`); + return { + content: source.content, + kind: 'local', + sourceKey: `local:${source.sha ?? ''}:${source.content.length}`, + } satisfies WorkspaceImageSource; + } + if (!source.sha) throw new Error(`Image blob is unavailable: ${repositoryPath}`); + return { kind: 'remote', sha: source.sha, sourceKey: `remote:${source.sha}` } satisfies WorkspaceImageSource; +}; + +export const clearGitHubImageUrlCache = () => { + imageUrlCache.forEach(({ objectUrl }) => { + URL.revokeObjectURL(objectUrl); + }); + imageUrlCache.clear(); + activeWorkspaceKey = ''; +}; + +export const activateGitHubImageWorkspace = (config?: GitHubWorkspaceConfig) => { + const workspaceKey = config ? getGitHubWorkspaceKey(config) : ''; + if (activeWorkspaceKey && activeWorkspaceKey !== workspaceKey) clearGitHubImageUrlCache(); + activeWorkspaceKey = workspaceKey; +}; + +export const removeGitHubImageUrl = (config: GitHubWorkspaceConfig, url: string) => { + const repositoryPath = getGitHubImageRepositoryPath(config, url); + if (!repositoryPath) return; + const cached = imageUrlCache.get(repositoryPath); + if (!cached) return; + URL.revokeObjectURL(cached.objectUrl); + imageUrlCache.delete(repositoryPath); +}; + +export const resolveGitHubImageUrl = async (config: GitHubWorkspaceConfig, url: string) => { + const repositoryPath = getGitHubImageRepositoryPath(config, url); + if (!repositoryPath) return url; + const workspaceKey = getGitHubWorkspaceKey(config); + activateGitHubImageWorkspace(config); + + const source = await getWorkspaceImageSource(config, repositoryPath); + const cached = imageUrlCache.get(repositoryPath); + if (cached?.workspaceKey === workspaceKey && cached.sourceKey === source.sourceKey) return cached.objectUrl; + if (cached) URL.revokeObjectURL(cached.objectUrl); + + // A remote SHA fully identifies immutable GitHub blob content, so cached + // previews can be reused without downloading the blob again. + const content = + source.kind === 'local' ? source.content : await getGitHubBlobBase64(config.owner, config.repo, source.sha); + const objectUrl = URL.createObjectURL(base64ToBlob(content, getImageMime(repositoryPath))); + imageUrlCache.set(repositoryPath, { objectUrl, sourceKey: source.sourceKey, workspaceKey }); + return objectUrl; +}; + +export const uploadGitHubImage = async (config: GitHubWorkspaceConfig, file: File) => { + if (file.size > GITHUB_MAX_REPOSITORY_FILE_SIZE_BYTES) { + throw new Error('GitHub does not accept regular repository files larger than 100 MiB.'); + } + await loadGitHubAssetsDirectory(config); + const bytes = new Uint8Array(await file.arrayBuffer()); + const extension = inferExtension(file, bytes); + const hash = await getContentHash(bytes); + const content = await fileToBase64(file); + const assetsPath = getGitHubAssetsPath(config); + + for (let suffix = 0; ; suffix += 1) { + const fileName = `${hash}${suffix ? `_${suffix}` : ''}.${extension}`; + const repositoryPath = `${assetsPath}/${fileName}`; + const existing = githubWorkspaceStore.getFileSource(repositoryPath); + if (!existing) { + await githubWorkspaceStore.createFile(repositoryPath, content, 'base64'); + return getGitHubImageUrl(config, fileName); + } + const existingContent = + existing.kind === 'local' + ? existing.contentEncoding === 'base64' + ? existing.content + : undefined + : existing.sha + ? await getGitHubBlobBase64(config.owner, config.repo, existing.sha) + : undefined; + if (existingContent === content) return getGitHubImageUrl(config, fileName); + } +}; diff --git a/packages/editor-core/src/configs/uploadConfig.ts b/packages/editor-core/src/configs/uploadConfig.ts index f52ac4c..cdf8b51 100644 --- a/packages/editor-core/src/configs/uploadConfig.ts +++ b/packages/editor-core/src/configs/uploadConfig.ts @@ -8,7 +8,7 @@ export type ImageUploader = (file: File) => Promise; export interface ImageUploadOptions { uploadImage?: ImageUploader; - getImageUrl?: (url: string) => string; + getImageUrl?: (url: string) => Promise | string; } const unavailableUploader: ImageUploader = () => { diff --git a/packages/editor-core/src/constants.ts b/packages/editor-core/src/constants.ts new file mode 100644 index 0000000..2076e20 --- /dev/null +++ b/packages/editor-core/src/constants.ts @@ -0,0 +1,17 @@ +/** Repository directory containing managed workspace images. */ +export const WORKSPACE_ASSET_DIRECTORY = '_assets'; + +/** Root-relative path segment used to identify managed workspace images. */ +export const WORKSPACE_ASSET_SEGMENT = `/${WORKSPACE_ASSET_DIRECTORY}/`; + +/** CSS class applied while an asynchronous image URL is resolving. */ +export const IMAGE_LOADING_CLASS = 'image-resolution-loading'; + +/** CSS class applied when an asynchronous image URL cannot be resolved or loaded. */ +export const IMAGE_ERROR_CLASS = 'image-resolution-error'; + +/** Duration that an editor search result remains highlighted, in milliseconds. */ +export const HIGHLIGHT_DURATION_MS = 3500; + +/** Default background color used by the editor's highlight mark. */ +export const DEFAULT_COLOR = '#ffff00'; diff --git a/packages/editor-core/src/crepe.ts b/packages/editor-core/src/crepe.ts index 28039f4..5a2a1b1 100644 --- a/packages/editor-core/src/crepe.ts +++ b/packages/editor-core/src/crepe.ts @@ -13,6 +13,7 @@ import { uploadImage as defaultUploadImage, type ImageUploadOptions, } from './configs/uploadConfig'; +import { trackImageUrlResolver } from './imageResolution'; import { containerPlugin } from './plugins/plugin-container'; import { headingPlugin } from './plugins/plugin-heading'; import { configureColorPicker, highlightMarkerPlugin } from './plugins/plugin-highlight'; @@ -26,7 +27,7 @@ export interface CrepeOptions extends ConfigCallbacks, ImageUploadOptions { export function getCrepe({ root, defaultValue, isDarkMode, onToast, uploadImage, getImageUrl }: CrepeOptions) { const imageUploader = uploadImage ?? defaultUploadImage; - const imageUrlResolver = getImageUrl ?? ((url: string) => url); + const imageUrlResolver = trackImageUrlResolver(getImageUrl ?? ((url: string) => url)); const crepe = new Crepe({ root, defaultValue, diff --git a/packages/editor-core/src/imageResolution.ts b/packages/editor-core/src/imageResolution.ts new file mode 100644 index 0000000..9b81a13 --- /dev/null +++ b/packages/editor-core/src/imageResolution.ts @@ -0,0 +1,52 @@ +export type ImageResolutionStatus = 'error' | 'loading' | 'resolved'; + +type ImageResolutionListener = (url: string, status: ImageResolutionStatus) => void; + +const statuses = new Map(); +const listeners = new Set(); + +const setStatus = (url: string, status: ImageResolutionStatus) => { + statuses.set(url, status); + listeners.forEach((listener) => { + listener(url, status); + }); +}; + +export const getImageResolutionStatus = (url: string) => statuses.get(url); + +export const subscribeToImageResolution = (listener: ImageResolutionListener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +/** + * Tracks asynchronous URL resolvers so the editor can distinguish an image + * that is still being fetched from one whose resolution has failed. + */ +export const trackImageUrlResolver = + (resolver: (url: string) => Promise | string) => + (url: string): Promise | string => { + try { + const result = resolver(url); + if (typeof result === 'string') { + setStatus(url, 'resolved'); + return result; + } + setStatus(url, 'loading'); + return result.then( + (resolvedUrl) => { + setStatus(url, 'resolved'); + return resolvedUrl; + }, + (error: unknown) => { + setStatus(url, 'error'); + throw error; + }, + ); + } catch (error) { + setStatus(url, 'error'); + throw error; + } + }; diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts index 19d7e98..d8c0de8 100644 --- a/packages/editor-core/src/index.ts +++ b/packages/editor-core/src/index.ts @@ -6,6 +6,7 @@ import './styles/index.scss'; export { editorViewCtx } from '@milkdown/kit/core'; export type { Ctx } from '@milkdown/kit/ctx'; export { outline } from '@milkdown/utils'; +export { WORKSPACE_ASSET_DIRECTORY, WORKSPACE_ASSET_SEGMENT } from './constants'; export { CrepeEditor } from './MilkdownEditor'; export type { CrepeEditorProps, CrepeEditorRef, EditorHeading } from './MilkdownEditor'; export { getCrepe } from './crepe'; diff --git a/packages/editor-core/src/mountedAddons.tsx b/packages/editor-core/src/mountedAddons.tsx index a2922cd..f3b7f1c 100644 --- a/packages/editor-core/src/mountedAddons.tsx +++ b/packages/editor-core/src/mountedAddons.tsx @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-magic-numbers */ import { Decoration, DecorationSet, type EditorView } from '@milkdown/kit/prose/view'; +import { HIGHLIGHT_DURATION_MS, IMAGE_ERROR_CLASS, IMAGE_LOADING_CLASS, WORKSPACE_ASSET_SEGMENT } from './constants'; +import { getImageResolutionStatus, subscribeToImageResolution } from './imageResolution'; import { throttle } from './utils'; export interface EditorAddonOptions { @@ -15,6 +17,82 @@ export interface EditorAddonOptions { let removers: (() => void)[] = []; +const getImageStateHost = (image: HTMLImageElement) => + image.closest('.image-wrapper, .milkdown-image-inline'); + +const isWorkspaceAssetUrl = (url: string) => url.startsWith('/') && url.includes(WORKSPACE_ASSET_SEGMENT); + +const setImageState = (image: HTMLImageElement, state: 'error' | 'loading' | 'resolved') => { + const host = getImageStateHost(image); + if (!host) return; + host.classList.toggle(IMAGE_LOADING_CLASS, state === 'loading'); + host.classList.toggle(IMAGE_ERROR_CLASS, state === 'error'); + if (state === 'loading') image.setAttribute('aria-busy', 'true'); + else image.removeAttribute('aria-busy'); +}; + +const syncImageResolutionState = (image: HTMLImageElement) => { + const source = image.getAttribute('src') ?? ''; + if (isWorkspaceAssetUrl(source)) { + setImageState(image, getImageResolutionStatus(source) === 'error' ? 'error' : 'loading'); + return; + } + const host = getImageStateHost(image); + if (!host?.classList.contains(IMAGE_LOADING_CLASS)) return; + if (!image.complete) return; + setImageState(image, image.naturalWidth > 0 ? 'resolved' : 'error'); +}; + +const syncImagesInNode = (node: Node) => { + if (!(node instanceof Element)) return; + if (node instanceof HTMLImageElement) syncImageResolutionState(node); + node.querySelectorAll('img').forEach((image) => { + syncImageResolutionState(image); + }); +}; + +function mountImageResolutionStates(editorDom: Element) { + const onLoad = (event: Event) => { + if (event.target instanceof HTMLImageElement) setImageState(event.target, 'resolved'); + }; + const onError = (event: Event) => { + if (!(event.target instanceof HTMLImageElement)) return; + const source = event.target.getAttribute('src') ?? ''; + // The browser cannot load repository-root asset URLs directly in GitHub + // mode. Ignore this expected error while the authenticated resolver runs. + if (isWorkspaceAssetUrl(source) && getImageResolutionStatus(source) !== 'error') return; + setImageState(event.target, 'error'); + }; + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes') { + syncImagesInNode(mutation.target); + return; + } + mutation.addedNodes.forEach(syncImagesInNode); + }); + }); + const unsubscribe = subscribeToImageResolution((url) => { + editorDom.querySelectorAll('img').forEach((image) => { + if (image.getAttribute('src') === url) syncImageResolutionState(image); + }); + }); + + editorDom.addEventListener('load', onLoad, true); + editorDom.addEventListener('error', onError, true); + observer.observe(editorDom, { attributes: true, attributeFilter: ['src'], childList: true, subtree: true }); + editorDom.querySelectorAll('img').forEach((image) => { + syncImageResolutionState(image); + }); + + removers.push(() => { + unsubscribe(); + observer.disconnect(); + editorDom.removeEventListener('load', onLoad, true); + editorDom.removeEventListener('error', onError, true); + }); +} + export function removeEvents() { removers.forEach((remove) => { remove(); @@ -55,6 +133,7 @@ export function mountEditorAddons(options: EditorAddonOptions) { const editorDom = document.querySelector('.milkdown'); if (editorDom) { + mountImageResolutionStates(editorDom); const enter = () => options.onBlurChange?.(false); const leave = () => options.onBlurChange?.(true); editorDom.addEventListener('mouseenter', enter); @@ -98,8 +177,6 @@ export function syncMirror(getMirrorContainer?: () => HTMLElement | null) { }); } -const HIGHLIGHT_DURATION_MS = 3500; - export function applyHighlightAndScroll( view: EditorView, from: number, diff --git a/packages/editor-core/src/plugins/plugin-highlight/index.ts b/packages/editor-core/src/plugins/plugin-highlight/index.ts index 6ae3652..cd3b67f 100644 --- a/packages/editor-core/src/plugins/plugin-highlight/index.ts +++ b/packages/editor-core/src/plugins/plugin-highlight/index.ts @@ -7,8 +7,9 @@ import { showColorPickerCommand, toggleColorPickerCommand } from './commnads'; import { remarkMarkColor } from './remark-mark-color'; import { colorPickTooltipAPI, colorOptionsConfig } from './slices'; import { colorPickTooltip } from './tooltip'; +import { DEFAULT_COLOR } from '../../constants'; -export const DEFAULT_COLOR = '#ffff00'; +export { DEFAULT_COLOR } from '../../constants'; export const highlightSchema = $markSchema('mark', () => ({ attrs: { color: { default: DEFAULT_COLOR, validate: 'string' } }, diff --git a/packages/editor-core/src/styles/editor.scss b/packages/editor-core/src/styles/editor.scss index 97b6f24..3e60373 100644 --- a/packages/editor-core/src/styles/editor.scss +++ b/packages/editor-core/src/styles/editor.scss @@ -40,6 +40,68 @@ } .milkdown-list-item-block .children p { margin: 0; } .milkdown-code-block p { font-size: unset; margin-bottom: unset; line-height: unset; } + + .image-wrapper.image-resolution-loading, + .milkdown-image-inline.image-resolution-loading, + .image-wrapper.image-resolution-error, + .milkdown-image-inline.image-resolution-error { + position: relative; + + > img { + visibility: hidden; + } + + &::after { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--crepe-color-outline); + } + } + + .image-wrapper.image-resolution-loading, + .image-wrapper.image-resolution-error { + min-width: 100px; + min-height: 100px; + } + + .milkdown-image-inline.image-resolution-loading, + .milkdown-image-inline.image-resolution-error { + min-width: 1.5em; + min-height: 1.5em; + } + + .image-wrapper.image-resolution-loading, + .milkdown-image-inline.image-resolution-loading { + &::after { + content: ''; + width: 22px; + height: 22px; + border: 2px solid color-mix(in srgb, var(--crepe-color-outline), transparent 65%); + border-top-color: var(--crepe-color-primary); + border-radius: 50%; + animation: image-resolution-spin 0.7s linear infinite; + } + } + + .image-wrapper.image-resolution-error, + .milkdown-image-inline.image-resolution-error { + &::after { + font-size: 0.8rem; + text-align: center; + } + } + + .image-wrapper.image-resolution-error::after { + content: 'Image unavailable'; + width: max-content; + max-width: calc(100% - 16px); + } + + .milkdown-image-inline.image-resolution-error::after { + content: '⚠'; + } } .search-match-highlight { @@ -55,3 +117,7 @@ 75% { background-color: transparent; } 100% { background-color: $searchHighlightColorSoft; } } + +@keyframes image-resolution-spin { + to { transform: translate(-50%, -50%) rotate(360deg); } +} diff --git a/packages/github-workspace/src/git-status.ts b/packages/github-workspace/src/git-status.ts index ef71131..4f118a2 100644 --- a/packages/github-workspace/src/git-status.ts +++ b/packages/github-workspace/src/git-status.ts @@ -86,7 +86,8 @@ const sameEntry = (left?: WorkspaceEntry, right?: WorkspaceEntry) => left.mode === right.mode && left.type === right.type && left.sha === right.sha && - left.content === right.content, + left.content === right.content && + left.contentEncoding === right.contentEncoding, ); const getOperationMetadata = (tree: TreeState, path: string, entry?: WorkspaceEntry) => @@ -194,6 +195,7 @@ export const getPublishEntries = (baseTree: TreeState, stagedTree: TreeState): P type: change.newEntry.type === 'commit' ? 'commit' : 'blob', sha: change.newEntry.content === undefined ? change.newEntry.sha ?? null : null, content: change.newEntry.content, + contentEncoding: change.newEntry.contentEncoding, }; }); diff --git a/packages/github-workspace/src/index.ts b/packages/github-workspace/src/index.ts index 9c9a0a6..c11953d 100644 --- a/packages/github-workspace/src/index.ts +++ b/packages/github-workspace/src/index.ts @@ -46,6 +46,7 @@ import type { RemoteDirectoryState, RemoteWorkspaceSnapshot, TreeState, + WorkspaceContentEncoding, WorkspaceDescriptor, WorkspacePersistence, WorkspaceRules, @@ -249,7 +250,14 @@ export class GitWorkspaceStore { public getFileSource(path: string) { const entry = getWorkspaceEntry(this.workingTree, this.movedPathAliases, path); if (!entry || entry.kind !== 'file') return undefined; - if (entry.content !== undefined) return { kind: 'local', content: entry.content, sha: entry.sha } as const; + if (entry.content !== undefined) { + return { + kind: 'local', + content: entry.content, + contentEncoding: entry.contentEncoding ?? 'utf-8', + sha: entry.sha, + } as const; + } return { kind: 'remote', sha: entry.sha } as const; } @@ -257,10 +265,10 @@ export class GitWorkspaceStore { * Creates a file in the working tree and records one operation group for it. * The resulting file is unstaged and the updated workspace is persisted. */ - public async createFile(path: string, content = '') { + public async createFile(path: string, content = '', contentEncoding: WorkspaceContentEncoding = 'utf-8') { assertInScope(this.descriptor, path, this.rules); return this._applyOperation(`Create file ${path}`, [path], (tree, operationMetadata) => { - createFileEntry(tree, this._getOpenDescriptor(), path, content, operationMetadata); + createFileEntry(tree, this._getOpenDescriptor(), path, content, contentEncoding, operationMetadata); }); } @@ -279,13 +287,13 @@ export class GitWorkspaceStore { * Replaces a working-tree file's content and persists the change. * Repeated writes reuse the file's operation group so they appear as one change. */ - public async writeFile(path: string, content: string) { + public async writeFile(path: string, content: string, contentEncoding: WorkspaceContentEncoding = 'utf-8') { assertInScope(this.descriptor, path, this.rules); const current = this.workingTree.entries[path]; if (!current || current.kind !== 'file') return Promise.reject(new Error(`The file ${path} does not exist.`)); const operationMetadata = current.operationMetadata ?? createOperationMetadata(`Update ${path}`, [path]); return this._applyOperationWithMetadata(operationMetadata, (tree) => { - writeFileEntry(tree, path, content, operationMetadata); + writeFileEntry(tree, path, content, contentEncoding, operationMetadata); }); } diff --git a/packages/github-workspace/src/types.ts b/packages/github-workspace/src/types.ts index e42af15..7a0358b 100644 --- a/packages/github-workspace/src/types.ts +++ b/packages/github-workspace/src/types.ts @@ -11,6 +11,7 @@ export enum GitMode { } export type GitObjectType = 'blob' | 'commit' | 'tree'; export type WorkspaceEntryKind = 'directory' | 'file'; +export type WorkspaceContentEncoding = 'base64' | 'utf-8'; export interface WorkspaceDescriptor { owner: string; @@ -79,6 +80,7 @@ export interface WorkspaceEntry { sha?: string; size?: number; content?: string; + contentEncoding?: WorkspaceContentEncoding; /** Records the user action that created this entry state. */ operationMetadata?: OperationMetadata; } @@ -132,6 +134,7 @@ export interface PublishTreeEntry { type: 'blob' | 'commit'; sha: string | null; content?: string; + contentEncoding?: WorkspaceContentEncoding; } export interface PublishPlan { diff --git a/packages/github-workspace/src/workspace-operations.ts b/packages/github-workspace/src/workspace-operations.ts index 9678fd6..27b4946 100644 --- a/packages/github-workspace/src/workspace-operations.ts +++ b/packages/github-workspace/src/workspace-operations.ts @@ -12,7 +12,14 @@ import { import { GitMode } from './types'; import { cleanPath, createId, createOperationMetadata, isProtectedPath, isSameOrDescendant, parentPath } from './utils'; -import type { OperationMetadata, TreeState, WorkspaceDescriptor, WorkspaceEntry, WorkspaceRules } from './types'; +import type { + OperationMetadata, + TreeState, + WorkspaceContentEncoding, + WorkspaceDescriptor, + WorkspaceEntry, + WorkspaceRules, +} from './types'; export const assertInScope = (descriptor: WorkspaceDescriptor | null, path: string, rules: WorkspaceRules) => { if (!descriptor) throw new Error('No GitHub workspace is open.'); @@ -86,6 +93,7 @@ export const createFileEntry = ( descriptor: WorkspaceDescriptor, path: string, content: string, + contentEncoding: WorkspaceContentEncoding, operationMetadata: OperationMetadata, ) => { assertAvailable(tree, path); @@ -97,6 +105,7 @@ export const createFileEntry = ( mode: GitMode.File, type: 'blob', content, + contentEncoding, operationMetadata, }; }; @@ -116,11 +125,13 @@ export const writeFileEntry = ( tree: TreeState, path: string, content: string, + contentEncoding: WorkspaceContentEncoding, operationMetadata: OperationMetadata, ) => { const entry = tree.entries[path]; if (!entry || entry.kind !== 'file') throw new Error(`The file ${path} does not exist.`); entry.content = content; + entry.contentEncoding = contentEncoding; entry.operationMetadata = operationMetadata; };