From 42cb5efa59487b466af9ac5928dbffc24d332ad7 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Fri, 7 Nov 2025 08:35:41 +0100 Subject: [PATCH 01/17] feat(oauth): support gitlab with self-hosted instances and agnostic git providers --- package.json | 1 + pnpm-lock.yaml | 190 +++++++++++++ src/app/src/composables/useGit.ts | 202 +++++++++++++- src/app/src/composables/useStudio.ts | 4 +- src/app/src/types/git.ts | 45 ++- src/app/src/types/user.ts | 6 +- src/module/src/module.ts | 54 +++- src/module/src/runtime/server/routes/admin.ts | 10 +- .../runtime/server/routes/auth/github.get.ts | 4 +- .../runtime/server/routes/auth/gitlab.get.ts | 260 ++++++++++++++++++ 10 files changed, 750 insertions(+), 26 deletions(-) create mode 100644 src/module/src/runtime/server/routes/auth/gitlab.get.ts diff --git a/package.json b/package.json index 955f38a2..46edc1da 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "unstorage": "1.17.1" }, "devDependencies": { + "@gitbeaker/core": "^43.8.0", "@iconify-json/simple-icons": "^1.2.57", "@nuxt/content": "^3.8.0", "@nuxt/eslint-config": "^1.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8fe1084e..b7c65992 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: specifier: 1.17.1 version: 1.17.1(db0@0.3.4(better-sqlite3@12.4.1))(idb-keyval@6.2.2)(ioredis@5.8.2) devDependencies: + '@gitbeaker/core': + specifier: ^43.8.0 + version: 43.8.0 '@iconify-json/simple-icons': specifier: ^1.2.57 version: 1.2.57 @@ -600,6 +603,14 @@ packages: '@floating-ui/vue@1.1.9': resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@gitbeaker/core@43.8.0': + resolution: {integrity: sha512-H+LfKuf4dExBinb79c+CXViRBvTVQNf5BYLNSizm2SiqdED5JruhKX88payefleY0szp7G/mySlFSXPyGRH1dQ==} + engines: {node: '>=18.20.0'} + + '@gitbeaker/requester-utils@43.8.0': + resolution: {integrity: sha512-d/SiJdxijc+aH5ZBQOw83XLxNSXqsBZNm5k3nPu1EHxGxK0fajXmxdMl0/vNXbKRggnIquFCxURkrQSEzfjqxQ==} + engines: {node: '>=18.20.0'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2817,6 +2828,14 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -3357,6 +3376,10 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3461,9 +3484,21 @@ packages: errx@0.1.0: resolution: {integrity: sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q==} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + esbuild@0.25.11: resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} engines: {node: '>=18'} @@ -3815,9 +3850,17 @@ packages: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} @@ -3891,6 +3934,10 @@ packages: resolution: {integrity: sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==} engines: {node: '>=20'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3913,6 +3960,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -4525,6 +4576,10 @@ packages: marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -4956,6 +5011,10 @@ packages: object-deep-merge@2.0.0: resolution: {integrity: sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg==} + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + ofetch@1.5.0: resolution: {integrity: sha512-A7llJ7eZyziA5xq9//3ZurA8OhFqtS99K5/V1sLBJ5j137CM/OAjlbA/TEJXBuOWwOfLqih+oH5U3ran4za1FQ==} @@ -5138,6 +5197,10 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch-browser@2.2.6: + resolution: {integrity: sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==} + engines: {node: '>=8.6'} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -5404,6 +5467,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} @@ -5420,6 +5487,9 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + rate-limiter-flexible@8.1.0: + resolution: {integrity: sha512-J+4xBdVboibP1h0Imn4nFoCLT+UM9Os9vJaWaRWkLsQxS7jrhLJeLlmzP5hyCEsLwtgFIIY5KcWiJGyyVTMaKg==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -5706,6 +5776,22 @@ packages: shiki@3.14.0: resolution: {integrity: sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g==} + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -6687,6 +6773,9 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xcase@2.0.1: + resolution: {integrity: sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} @@ -7230,6 +7319,19 @@ snapshots: - '@vue/composition-api' - vue + '@gitbeaker/core@43.8.0': + dependencies: + '@gitbeaker/requester-utils': 43.8.0 + qs: 6.14.0 + xcase: 2.0.1 + + '@gitbeaker/requester-utils@43.8.0': + dependencies: + picomatch-browser: 2.2.6 + qs: 6.14.0 + rate-limiter-flexible: 8.1.0 + xcase: 2.0.1 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -10530,6 +10632,16 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} camelize@1.0.1: {} @@ -11120,6 +11232,12 @@ snapshots: dotenv@17.2.3: {} + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} @@ -11208,8 +11326,16 @@ snapshots: errx@0.1.0: {} + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + esbuild@0.25.11: optionalDependencies: '@esbuild/aix-ppc64': 0.25.11 @@ -11634,8 +11760,26 @@ snapshots: get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + get-port-please@3.2.0: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + get-stream@8.0.1: {} get-stream@9.0.1: @@ -11729,6 +11873,8 @@ snapshots: slash: 5.1.0 unicorn-magic: 0.3.0 + gopd@1.2.0: {} + graceful-fs@4.2.11: {} graphemer@1.4.0: {} @@ -11760,6 +11906,8 @@ snapshots: has-flag@4.0.0: {} + has-symbols@1.1.0: {} + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -12439,6 +12587,8 @@ snapshots: marky@1.3.0: {} + math-intrinsics@1.1.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -13401,6 +13551,8 @@ snapshots: object-deep-merge@2.0.0: {} + object-inspect@1.13.4: {} + ofetch@1.5.0: dependencies: destr: 2.0.5 @@ -13654,6 +13806,8 @@ snapshots: picocolors@1.1.1: {} + picomatch-browser@2.2.6: {} + picomatch@2.3.1: {} picomatch@4.0.3: {} @@ -13913,6 +14067,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + quansync@0.2.11: {} queue-microtask@1.2.3: {} @@ -13925,6 +14083,8 @@ snapshots: range-parser@1.2.1: {} + rate-limiter-flexible@8.1.0: {} + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -14375,6 +14535,34 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@4.1.0: {} @@ -15431,6 +15619,8 @@ snapshots: dependencies: is-wsl: 3.1.0 + xcase@2.0.1: {} + xml-name-validator@4.0.0: {} xmlhttprequest-ssl@2.1.2: {} diff --git a/src/app/src/composables/useGit.ts b/src/app/src/composables/useGit.ts index b9bebc32..1490f904 100644 --- a/src/app/src/composables/useGit.ts +++ b/src/app/src/composables/useGit.ts @@ -1,23 +1,25 @@ import { ofetch } from 'ofetch' import { createSharedComposable } from '@vueuse/core' -import type { RawFile, GithubFile, GitOptions, CommitFilesOptions } from '../types' +import type { RawFile, GitFile, GitOptions, CommitFilesOptions, CommitResult, GitProvider } from '../types' import { DraftStatus } from '../types/draft' import { joinURL } from 'ufo' export const useDevelopmentGit = (_options: GitOptions) => { return { - fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), - commitFiles: (_files: RawFile[], _message: string): Promise<{ success: boolean, commitSha: string, url: string } | null> => Promise.resolve(null), + fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), + commitFiles: (_files: RawFile[], _message: string): Promise => Promise.resolve(null), getRepositoryUrl: () => '', getBranchUrl: () => '', + getCommitUrl: () => '', getContentRootDirUrl: () => '', - getRepositoryInfo: () => ({ owner: '', repo: '', branch: '' }), + getRepositoryInfo: () => ({ owner: '', repo: '', branch: '', provider: 'github' }), } } -export const useGit = createSharedComposable(({ owner, repo, token, branch, rootDir, authorName, authorEmail }: GitOptions) => { - const gitFiles: Record = {} +function createGitHubProvider(options: GitOptions): GitProvider { + const { owner, repo, token, branch, rootDir, authorName, authorEmail } = options + const gitFiles: Record = {} // Support both token formats: "token {token}" for classic PATs, "Bearer {token}" for OAuth/fine-grained PATs const authHeader = token.startsWith('ghp_') ? `token ${token}` : `Bearer ${token}` @@ -30,7 +32,7 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root }, }) - async function fetchFile(path: string, { cached = false }: { cached?: boolean } = {}): Promise { + async function fetchFile(path: string, { cached = false }: { cached?: boolean } = {}): Promise { path = joinURL(rootDir, path) if (cached) { const file = gitFiles[path] @@ -40,7 +42,7 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root } try { - const ghFile: GithubFile = await $api(`/contents/${path}?ref=${branch}`) + const ghFile: GitFile = await $api(`/contents/${path}?ref=${branch}`) if (cached) { gitFiles[path] = ghFile } @@ -64,7 +66,7 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root } } - function commitFiles(files: RawFile[], message: string): Promise<{ success: boolean, commitSha: string, url: string } | null> { + function commitFiles(files: RawFile[], message: string): Promise { if (!token) { return Promise.resolve(null) } @@ -168,6 +170,10 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root return `https://github.com/${owner}/${repo}/tree/${branch}` } + function getCommitUrl(sha: string) { + return `https://github.com/${owner}/${repo}/commit/${sha}` + } + function getContentRootDirUrl() { return `https://github.com/${owner}/${repo}/tree/${branch}/${rootDir}/content` } @@ -177,6 +183,172 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root owner, repo, branch, + provider: 'github' as const, + } + } + + return { + fetchFile, + commitFiles, + getRepositoryUrl, + getBranchUrl, + getCommitUrl, + getContentRootDirUrl, + getRepositoryInfo, + } +} + +function createGitLabProvider(options: GitOptions): GitProvider { + const { owner, repo, token, branch, rootDir, authorName, authorEmail, instanceUrl = 'https://gitlab.com' } = options + const gitFiles: Record = {} + + // GitLab uses project path (namespace/project) encoded as project ID + const projectPath = encodeURIComponent(`${owner}/${repo}`) + const baseURL = `${instanceUrl}/api/v4` + + const $api = ofetch.create({ + baseURL: `${baseURL}/projects/${projectPath}`, + headers: { + Authorization: `Bearer ${token}`, + }, + }) + + async function fetchFile(path: string, { cached = false }: { cached?: boolean } = {}): Promise { + path = joinURL(rootDir, path) + if (cached) { + const file = gitFiles[path] + if (file) { + return file + } + } + + try { + const encodedPath = encodeURIComponent(path) + const glFile = await $api(`/repository/files/${encodedPath}/raw?ref=${branch}`) + + // Get file metadata + const fileMetadata = await $api(`/repository/files/${encodedPath}?ref=${branch}`) + + const gitFile: GitFile = { + name: path.split('/').pop() || path, + path, + sha: fileMetadata.blob_id, + size: fileMetadata.size, + url: fileMetadata.file_path, + content: typeof glFile === 'string' ? glFile : undefined, + encoding: fileMetadata.encoding, + } + + if (cached) { + gitFiles[path] = gitFile + } + return gitFile + } + catch (error) { + // Handle different types of errors gracefully + if ((error as { status?: number }).status === 404) { + console.warn(`File not found on GitLab: ${path}`) + return null + } + + // For development, show alert. In production, you might want to use a toast notification + if (process.env.NODE_ENV === 'development') { + alert(`Failed to fetch file: ${path}\n${(error as { message?: string }).message || error}`) + } + + console.error(`Failed to fetch file from GitLab: ${path}`, error) + + return null + } + } + + function commitFiles(files: RawFile[], message: string): Promise { + if (!token) { + return Promise.resolve(null) + } + + files = files + .filter(file => file.status !== DraftStatus.Pristine) + .map(file => ({ ...file, path: joinURL(rootDir, file.path) })) + + return commitFilesToGitLab({ + owner, + repo, + branch, + files, + message, + authorName, + authorEmail, + }) + } + + async function commitFilesToGitLab({ branch, files, message, authorName, authorEmail }: CommitFilesOptions) { + // GitLab uses a single commits API with actions + const actions = files.map((file) => { + if (file.status === DraftStatus.Deleted) { + return { + action: 'delete', + file_path: file.path, + } + } + else if (file.status === DraftStatus.Created) { + return { + action: 'create', + file_path: file.path, + content: file.content, + encoding: file.encoding === 'base64' ? 'base64' : 'text', + } + } + else { + return { + action: 'update', + file_path: file.path, + content: file.content, + encoding: file.encoding === 'base64' ? 'base64' : 'text', + } + } + }) + + const commitData = await $api(`/repository/commits`, { + method: 'POST', + body: { + branch, + commit_message: message, + actions, + author_name: authorName, + author_email: authorEmail, + }, + }) + + return { + success: true, + commitSha: commitData.id, + url: `${instanceUrl}/${owner}/${repo}/-/commit/${commitData.id}`, + } + } + + function getRepositoryUrl() { + return `${instanceUrl}/${owner}/${repo}` + } + + function getBranchUrl() { + return `${instanceUrl}/${owner}/${repo}/-/tree/${branch}` + } + + function getCommitUrl(sha: string) { + return `${instanceUrl}/${owner}/${repo}/-/commit/${sha}` + } + + function getContentRootDirUrl() { + return `${instanceUrl}/${owner}/${repo}/-/tree/${branch}/${rootDir}/content` + } + + function getRepositoryInfo() { + return { + owner, + repo, + branch, + provider: 'gitlab' as const, } } @@ -185,7 +357,19 @@ export const useGit = createSharedComposable(({ owner, repo, token, branch, root commitFiles, getRepositoryUrl, getBranchUrl, + getCommitUrl, getContentRootDirUrl, getRepositoryInfo, } +} + +export const useGit = createSharedComposable((options: GitOptions): GitProvider => { + const provider = options.provider || 'github' + + if (provider === 'gitlab') { + return createGitLabProvider(options) + } + else { + return createGitHubProvider(options) + } }) diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index 2183ea82..d62b2a2d 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -24,13 +24,15 @@ export const useStudio = createSharedComposable(() => { studioFlags.dev = host.meta.dev const gitOptions: GitOptions = { + provider: host.repository.provider, owner: host.repository.owner, repo: host.repository.repo, branch: host.repository.branch, rootDir: host.repository.rootDir, - token: host.user.get().githubToken, + token: host.user.get().accessToken, authorName: host.user.get().name, authorEmail: host.user.get().email, + instanceUrl: host.repository.instanceUrl, } const git = studioFlags.dev ? useDevelopmentGit(gitOptions) : useGit(gitOptions) diff --git a/src/app/src/types/git.ts b/src/app/src/types/git.ts index dcf942a1..b36285dc 100644 --- a/src/app/src/types/git.ts +++ b/src/app/src/types/git.ts @@ -1,11 +1,18 @@ import type { DraftStatus } from './draft' +export type GitProviderType = 'github' | 'gitlab' + export interface Repository { - provider: 'github' + provider: GitProviderType owner: string repo: string branch: string rootDir: string + /** + * Can be used to specify the instance URL for self-hosted GitLab instances. + * @default 'https://gitlab.com' + */ + instanceUrl?: string } export interface GitBaseOptions { @@ -17,8 +24,10 @@ export interface GitBaseOptions { } export interface GitOptions extends GitBaseOptions { + provider: GitProviderType rootDir: string token: string + instanceUrl?: string } export interface CommitFilesOptions extends GitBaseOptions { @@ -33,22 +42,48 @@ export interface RawFile { encoding?: 'utf-8' | 'base64' } -// GITHUB -export interface GithubFile { +export interface GitProvider { + fetchFile(path: string, options?: { cached?: boolean }): Promise + commitFiles(files: RawFile[], message: string): Promise + getRepositoryUrl(): string + getBranchUrl(): string + getCommitUrl(sha: string): string + getContentRootDirUrl(): string + getRepositoryInfo(): { owner: string, repo: string, branch: string, provider: GitProviderType } +} + +export interface CommitResult { + success: boolean + commitSha: string + url: string +} + +export interface GitFile { name: string path: string sha: string size: number url: string + content?: string + encoding?: string +} + +export interface GithubFile extends GitFile { html_url: string git_url: string download_url: string type: string - content?: string - encoding?: string _links: { self: string git: string html: string } } + +export interface GitLabFile extends GitFile { + file_path: string + ref: string + blob_id: string + commit_id: string + last_commit_id: string +} diff --git a/src/app/src/types/user.ts b/src/app/src/types/user.ts index 2abe1c78..7028afe9 100644 --- a/src/app/src/types/user.ts +++ b/src/app/src/types/user.ts @@ -1,8 +1,8 @@ export interface StudioUser { - githubId: string - githubToken: string + providerId: string + accessToken: string name: string avatar: string email: string - provider: 'github' | 'google' + provider: 'github' | 'gitlab' | 'google' } diff --git a/src/module/src/module.ts b/src/module/src/module.ts index cbe7fb4f..d6481198 100644 --- a/src/module/src/module.ts +++ b/src/module/src/module.ts @@ -34,6 +34,26 @@ interface ModuleOptions { */ clientSecret?: string } + /** + * The GitLab OAuth credentials. + */ + gitlab?: { + /** + * The GitLab OAuth application ID. + * @default process.env.STUDIO_GITLAB_APPLICATION_ID + */ + applicationId?: string + /** + * The GitLab OAuth application secret. + * @default process.env.STUDIO_GITLAB_APPLICATION_SECRET + */ + applicationSecret?: string + /** + * The GitLab instance URL (for self-hosted instances). + * @default 'https://gitlab.com' + */ + instanceUrl?: string + } } /** * The git repository information to connect to. @@ -43,7 +63,7 @@ interface ModuleOptions { * The provider to use for the git repository. * @default 'github' */ - provider?: 'github' + provider?: 'github' | 'gitlab' /** * The owner of the git repository. */ @@ -68,6 +88,11 @@ interface ModuleOptions { * @default true */ private?: boolean + /** + * The GitLab instance URL (for self-hosted GitLab). + * @default 'https://gitlab.com' + */ + instanceUrl?: string } /** * Enable Nuxt Studio to edit content and media files on your filesystem. @@ -102,6 +127,11 @@ export default defineNuxtModule({ clientId: process.env.STUDIO_GITHUB_CLIENT_ID, clientSecret: process.env.STUDIO_GITHUB_CLIENT_SECRET, }, + gitlab: { + applicationId: process.env.STUDIO_GITLAB_APPLICATION_ID, + applicationSecret: process.env.STUDIO_GITLAB_APPLICATION_SECRET, + instanceUrl: process.env.STUDIO_GITLAB_INSTANCE_URL || 'https://gitlab.com', + }, }, development: { sync: false, @@ -116,12 +146,22 @@ export default defineNuxtModule({ } if (!nuxt.options.dev && !nuxt.options._prepare) { - if (!options.auth?.github?.clientId && !options.auth?.github?.clientSecret) { + const provider = options.repository?.provider || 'github' + const hasGitHubAuth = options.auth?.github?.clientId && options.auth?.github?.clientSecret + const hasGitLabAuth = options.auth?.gitlab?.applicationId && options.auth?.gitlab?.applicationSecret + + if (provider === 'github' && !hasGitHubAuth) { logger.warn([ - 'Nuxt Content Studio relies on GitHub OAuth to authenticate users.', + 'Nuxt Content Studio requires GitHub OAuth to authenticate users.', 'Please set the `STUDIO_GITHUB_CLIENT_ID` and `STUDIO_GITHUB_CLIENT_SECRET` environment variables.', ].join(' ')) } + else if (provider === 'gitlab' && !hasGitLabAuth) { + logger.warn([ + 'Nuxt Content Studio requires GitLab OAuth to authenticate users.', + 'Please set the `STUDIO_GITLAB_APPLICATION_ID` and `STUDIO_GITLAB_APPLICATION_SECRET` environment variables.', + ].join(' ')) + } } // Enable checkoutOutdatedBuildInterval to detect new deployments @@ -143,9 +183,13 @@ export default defineNuxtModule({ sessionSecret: createHash('md5').update([ options.auth?.github?.clientId, options.auth?.github?.clientSecret, + options.auth?.gitlab?.applicationId, + options.auth?.gitlab?.applicationSecret, ].join('')).digest('hex'), // @ts-expect-error todo fix github type issue github: options.auth?.github, + // @ts-expect-error autogenerated type doesn't match with project options + gitlab: options.auth?.gitlab, }, // @ts-expect-error Autogenerated type does not match with options repository: options.repository, @@ -229,6 +273,10 @@ export default defineNuxtModule({ route: '/__nuxt_studio/auth/github', handler: runtime('./server/routes/auth/github.get'), }) + addServerHandler({ + route: '/__nuxt_studio/auth/gitlab', + handler: runtime('./server/routes/auth/gitlab.get'), + }) addServerHandler({ route: '/__nuxt_studio/auth/session', handler: runtime('./server/routes/auth/session.get'), diff --git a/src/module/src/runtime/server/routes/admin.ts b/src/module/src/runtime/server/routes/admin.ts index f6b7cd5d..12eeae84 100644 --- a/src/module/src/runtime/server/routes/admin.ts +++ b/src/module/src/runtime/server/routes/admin.ts @@ -1,4 +1,5 @@ import { eventHandler, getQuery, sendRedirect, setCookie } from 'h3' +import { useRuntimeConfig } from '#imports' export default eventHandler((event) => { const { redirect } = getQuery(event) @@ -7,9 +8,12 @@ export default eventHandler((event) => { httpOnly: true, }) } - // Automatically redirect to the GitHub login page - // TODO: once there is more than one auth provider, we need to add a selector for the auth provider - return sendRedirect(event, '/__nuxt_studio/auth/github') + + // Automatically redirect to the configured provider's OAuth endpoint + const config = useRuntimeConfig(event) + const provider = config.public.studio?.repository?.provider || 'github' + + return sendRedirect(event, `/__nuxt_studio/auth/${provider}`) // HTML Generated by v0 return ` diff --git a/src/module/src/runtime/server/routes/auth/github.get.ts b/src/module/src/runtime/server/routes/auth/github.get.ts index caba801b..306dcdc3 100644 --- a/src/module/src/runtime/server/routes/auth/github.get.ts +++ b/src/module/src/runtime/server/routes/auth/github.get.ts @@ -204,8 +204,8 @@ export default eventHandler(async (event: H3Event) => { await session.update(defu({ user: { contentUser: true, - githubId: user.id, - githubToken: token.access_token, + providerId: user.id.toString(), + accessToken: token.access_token, name: user.name || user.login, avatar: user.avatar_url, email: user.email, diff --git a/src/module/src/runtime/server/routes/auth/gitlab.get.ts b/src/module/src/runtime/server/routes/auth/gitlab.get.ts new file mode 100644 index 00000000..c7e02c09 --- /dev/null +++ b/src/module/src/runtime/server/routes/auth/gitlab.get.ts @@ -0,0 +1,260 @@ +import { FetchError } from 'ofetch' +import { getRandomValues } from 'uncrypto' +import type { H3Event } from 'h3' +import { eventHandler, getQuery, sendRedirect, createError, getRequestURL, setCookie, deleteCookie, getCookie, useSession } from 'h3' +import { withQuery } from 'ufo' +import { defu } from 'defu' +import type { UserSchema } from '@gitbeaker/core' +import { useRuntimeConfig } from '#imports' + +export interface OAuthGitLabConfig { + /** + * GitLab OAuth Application ID + * @default process.env.STUDIO_GITLAB_APPLICATION_ID + */ + applicationId?: string + /** + * GitLab OAuth Application Secret + * @default process.env.STUDIO_GITLAB_APPLICATION_SECRET + */ + applicationSecret?: string + /** + * GitLab OAuth Scope + * @default [] + * @see https://docs.gitlab.com/ee/integration/oauth_provider.html#authorized-applications + */ + scope?: string[] + /** + * Require email from user + * @default false + */ + emailRequired?: boolean + + /** + * GitLab instance URL + * @default 'https://gitlab.com' + */ + instanceUrl?: string + + /** + * GitLab OAuth Authorization URL + * @default '{instanceUrl}/oauth/authorize' + */ + authorizationURL?: string + + /** + * GitLab OAuth Token URL + * @default '{instanceUrl}/oauth/token' + */ + tokenURL?: string + + /** + * GitLab API URL + * @default '{instanceUrl}/api/v4' + */ + apiURL?: string + + /** + * Extra authorization parameters to provide to the authorization URL + */ + authorizationParams?: Record + + /** + * Redirect URL to allow overriding for situations like prod failing to determine public hostname + * Use `process.env.STUDIO_GITLAB_REDIRECT_URL` to overwrite the default redirect URL. + * @default is ${hostname}/__nuxt_studio/auth/gitlab + */ + redirectURL?: string +} + +interface RequestAccessTokenResponse { + access_token?: string + token_type?: string + refresh_token?: string + expires_in?: number + created_at?: number + error?: string + error_description?: string +} + +interface RequestAccessTokenOptions { + body?: Record + params?: Record +} + +export default eventHandler(async (event: H3Event) => { + const studioConfig = useRuntimeConfig(event).studio + const instanceUrl = studioConfig?.auth?.gitlab?.instanceUrl || 'https://gitlab.com' + + const config = defu(studioConfig?.auth?.gitlab, { + applicationId: process.env.STUDIO_GITLAB_APPLICATION_ID, + applicationSecret: process.env.STUDIO_GITLAB_APPLICATION_SECRET, + redirectURL: process.env.STUDIO_GITLAB_REDIRECT_URL, + instanceUrl, + authorizationURL: `${instanceUrl}/oauth/authorize`, + tokenURL: `${instanceUrl}/oauth/token`, + apiURL: `${instanceUrl}/api/v4`, + authorizationParams: {}, + emailRequired: true, + }) as OAuthGitLabConfig + + const query = getQuery<{ code?: string, error?: string, state?: string }>(event) + + if (query.error) { + throw createError({ + statusCode: 401, + message: `GitLab login failed: ${query.error || 'Unknown error'}`, + data: query, + }) + } + + if (!config.applicationId || !config.applicationSecret) { + throw createError({ + statusCode: 500, + message: 'Missing GitLab application ID or secret', + data: config, + }) + } + + const requestURL = getRequestURL(event) + + config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}` + + const state = await handleState(event) + + if (!query.code) { + config.scope = config.scope || [] + if (!config.scope.includes('api')) { + config.scope.push('api') + } + + return sendRedirect( + event, + withQuery(config.authorizationURL as string, { + client_id: config.applicationId, + redirect_uri: config.redirectURL, + response_type: 'code', + scope: config.scope.join(' '), + state, + ...config.authorizationParams, + }), + ) + } + + if (query.state !== state) { + throw createError({ + statusCode: 500, + message: 'Invalid state', + data: { + query, + state, + }, + }) + } + + const token = await requestAccessToken(config.tokenURL as string, { + body: { + grant_type: 'authorization_code', + client_id: config.applicationId, + client_secret: config.applicationSecret, + redirect_uri: config.redirectURL, + code: query.code, + }, + }) + + if (token.error || !token.access_token) { + throw createError({ + statusCode: 500, + message: 'Failed to get access token', + data: token, + }) + } + + const accessToken = token.access_token + + const user: UserSchema = await $fetch(`${config.apiURL}/user`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!user.email && config.emailRequired) { + throw createError({ + statusCode: 500, + message: 'Could not get GitLab user email', + data: token, + }) + } + + // Success + const session = await useSession(event, { + name: 'studio-session', + password: useRuntimeConfig(event).studio?.auth?.sessionSecret, + }) + + await session.update(defu({ + user: { + contentUser: true, + providerId: user.id.toString(), + accessToken: token.access_token, + name: user.name || user.username, + avatar: user.avatar_url, + email: user.email, + provider: 'gitlab', + }, + }, session.data)) + + const redirect = decodeURIComponent(getCookie(event, 'studio-redirect') || '/') + deleteCookie(event, 'studio-redirect') + // make sure the redirect is a valid relative path (avoid also // which is not a valid URL) + if (redirect && redirect.startsWith('/') && !redirect.startsWith('//')) { + return sendRedirect(event, redirect) + } + + return sendRedirect(event, '/') +}) + +async function requestAccessToken(url: string, options: RequestAccessTokenOptions): Promise { + try { + return await $fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: options.body, + params: options.params, + }) + } + catch (error) { + if (error instanceof FetchError) { + return error.data || { error: error.message } + } + return { error: 'Unknown error' } + } +} + +async function handleState(event: H3Event) { + const state = getCookie(event, 'studio-oauth-state') + + if (state) { + deleteCookie(event, 'studio-oauth-state') + return state + } + + const newState = Array.from(getRandomValues(new Uint8Array(32))) + .map(b => b.toString(16).padStart(2, '0')) + .join('') + + const requestURL = getRequestURL(event) + // Use secure cookies over HTTPS, required for locally testing purposes + const isSecure = requestURL.protocol === 'https:' + + setCookie(event, 'studio-oauth-state', newState, { + httpOnly: true, + secure: isSecure, + sameSite: 'lax', + maxAge: 60 * 15, // 15 minutes + }) + + return newState +} From 6586773fb471798fd30d4b988c09ff46de63fe6f Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Fri, 7 Nov 2025 09:50:19 +0100 Subject: [PATCH 02/17] fix(oauth): better handling of state --- .../runtime/server/routes/auth/gitlab.get.ts | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/module/src/runtime/server/routes/auth/gitlab.get.ts b/src/module/src/runtime/server/routes/auth/gitlab.get.ts index c7e02c09..62ecaaa6 100644 --- a/src/module/src/runtime/server/routes/auth/gitlab.get.ts +++ b/src/module/src/runtime/server/routes/auth/gitlab.get.ts @@ -120,9 +120,10 @@ export default eventHandler(async (event: H3Event) => { config.redirectURL = config.redirectURL || `${requestURL.protocol}//${requestURL.host}${requestURL.pathname}` - const state = await handleState(event) - if (!query.code) { + // Initial authorization request (generate and store state) + const state = await generateState(event) + config.scope = config.scope || [] if (!config.scope.includes('api')) { config.scope.push('api') @@ -141,17 +142,32 @@ export default eventHandler(async (event: H3Event) => { ) } - if (query.state !== state) { + // Callback with code (validate and consume state) + const storedState = getCookie(event, 'studio-oauth-state') + + if (!storedState) { throw createError({ - statusCode: 500, - message: 'Invalid state', + statusCode: 400, + message: 'OAuth state cookie not found. Please try logging in again.', data: { - query, - state, + hint: 'State cookie may have expired or been cleared', }, }) } + if (query.state !== storedState) { + throw createError({ + statusCode: 400, + message: 'Invalid state - OAuth state mismatch', + data: { + hint: 'This may be caused by browser refresh, navigation, or expired session', + }, + }) + } + + // State validated, delete the cookie + deleteCookie(event, 'studio-oauth-state') + const token = await requestAccessToken(config.tokenURL as string, { body: { grant_type: 'authorization_code', @@ -233,14 +249,7 @@ async function requestAccessToken(url: string, options: RequestAccessTokenOption } } -async function handleState(event: H3Event) { - const state = getCookie(event, 'studio-oauth-state') - - if (state) { - deleteCookie(event, 'studio-oauth-state') - return state - } - +async function generateState(event: H3Event) { const newState = Array.from(getRandomValues(new Uint8Array(32))) .map(b => b.toString(16).padStart(2, '0')) .join('') From 02d7409c849c18f8aa663c5e6c215ed302004b8b Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Fri, 7 Nov 2025 11:06:28 +0100 Subject: [PATCH 03/17] fix(studio): fetch base64 encoded files from GitLab --- src/app/src/composables/useGit.ts | 13 ++++++------- src/app/src/types/git.ts | 3 ++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/app/src/composables/useGit.ts b/src/app/src/composables/useGit.ts index 1490f904..a41e8f07 100644 --- a/src/app/src/composables/useGit.ts +++ b/src/app/src/composables/useGit.ts @@ -224,9 +224,7 @@ function createGitLabProvider(options: GitOptions): GitProvider { try { const encodedPath = encodeURIComponent(path) - const glFile = await $api(`/repository/files/${encodedPath}/raw?ref=${branch}`) - - // Get file metadata + // GitLab API returns base64-encoded content when using /repository/files endpoint (without /raw) const fileMetadata = await $api(`/repository/files/${encodedPath}?ref=${branch}`) const gitFile: GitFile = { @@ -235,8 +233,9 @@ function createGitLabProvider(options: GitOptions): GitProvider { sha: fileMetadata.blob_id, size: fileMetadata.size, url: fileMetadata.file_path, - content: typeof glFile === 'string' ? glFile : undefined, - encoding: fileMetadata.encoding, + content: fileMetadata.content, + encoding: 'base64' as const, + provider: 'gitlab' as const, } if (cached) { @@ -251,13 +250,13 @@ function createGitLabProvider(options: GitOptions): GitProvider { return null } + console.error(`Failed to fetch file from GitLab: ${path}`, error) + // For development, show alert. In production, you might want to use a toast notification if (process.env.NODE_ENV === 'development') { alert(`Failed to fetch file: ${path}\n${(error as { message?: string }).message || error}`) } - console.error(`Failed to fetch file from GitLab: ${path}`, error) - return null } } diff --git a/src/app/src/types/git.ts b/src/app/src/types/git.ts index b36285dc..c12f403a 100644 --- a/src/app/src/types/git.ts +++ b/src/app/src/types/git.ts @@ -59,13 +59,14 @@ export interface CommitResult { } export interface GitFile { + provider: GitProviderType name: string path: string sha: string size: number url: string content?: string - encoding?: string + encoding?: 'utf-8' | 'base64' } export interface GithubFile extends GitFile { From 963a4e723ade58d29ae22eba35b1a52b39e22188 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Fri, 7 Nov 2025 11:07:34 +0100 Subject: [PATCH 04/17] refactor(studio): rename githubFile to remoteFile --- src/app/src/components/content/ContentCardReview.vue | 2 +- src/app/src/components/content/ContentEditorCode.vue | 6 +++--- src/app/src/composables/useDraftBase.ts | 10 +++++----- src/app/src/composables/useDraftMedias.ts | 2 +- src/app/src/types/draft.ts | 2 +- src/app/src/utils/draft.ts | 8 ++++---- src/app/test/mocks/git.ts | 5 +++-- 7 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/app/src/components/content/ContentCardReview.vue b/src/app/src/components/content/ContentCardReview.vue index fc817971..b1acd308 100644 --- a/src/app/src/components/content/ContentCardReview.vue +++ b/src/app/src/components/content/ContentCardReview.vue @@ -90,7 +90,7 @@ async function initializeEditor() { isLoadingContent.value = true const localOriginal = props.draftItem.original ? await generateContentFromDocument(props.draftItem.original as DatabaseItem) : null - const gitHubOriginal = props.draftItem.githubFile?.content ? fromBase64ToUTF8(props.draftItem.githubFile.content) : null + const gitHubOriginal = props.draftItem.remoteFile?.content ? fromBase64ToUTF8(props.draftItem.remoteFile.content) : null const modified = props.draftItem.modified ? await generateContentFromDocument(props.draftItem.modified as DatabasePageItem) : null isAutomaticFormattingDetected.value = !isEqual(localOriginal, gitHubOriginal) diff --git a/src/app/src/components/content/ContentEditorCode.vue b/src/app/src/components/content/ContentEditorCode.vue index 892d4e26..76e48fa0 100644 --- a/src/app/src/components/content/ContentEditorCode.vue +++ b/src/app/src/components/content/ContentEditorCode.vue @@ -116,11 +116,11 @@ async function setContent(document: DatabasePageItem) { currentDocumentId.value = document.id isAutomaticFormattingDetected.value = false - if (props.draftItem.original && props.draftItem.githubFile?.content) { + if (props.draftItem.original && props.draftItem.remoteFile?.content) { const localOriginal = await generateContentFromDocument(props.draftItem.original as DatabaseItem) - const gitHubOriginal = fromBase64ToUTF8(props.draftItem.githubFile.content) + const remoteOriginal = props.draftItem.remoteFile.encoding === 'base64' ? fromBase64ToUTF8(props.draftItem.remoteFile.content!) : props.draftItem.remoteFile.content! - isAutomaticFormattingDetected.value = !isEqual(localOriginal, gitHubOriginal) + isAutomaticFormattingDetected.value = !isEqual(localOriginal, remoteOriginal) } } diff --git a/src/app/src/composables/useDraftBase.ts b/src/app/src/composables/useDraftBase.ts index a217bdbe..8d26aa07 100644 --- a/src/app/src/composables/useDraftBase.ts +++ b/src/app/src/composables/useDraftBase.ts @@ -34,12 +34,12 @@ export function useDraftBase( } const fsPath = hostDb.getFileSystemPath(item.id) - const githubFile = await git.fetchFile(joinURL(ghPathPrefix, fsPath), { cached: true }) as GithubFile + const remoteFile = await git.fetchFile(joinURL(ghPathPrefix, fsPath), { cached: true }) as GithubFile const draftItem: DraftItem = { id: item.id, fsPath, - githubFile, + remoteFile, status: getDraftStatus(item, original), modified: item, } @@ -86,7 +86,7 @@ export function useDraftBase( fsPath: existingDraftItem.fsPath, status: DraftStatus.Deleted, original: existingDraftItem.original, - githubFile: existingDraftItem.githubFile, + remoteFile: existingDraftItem.remoteFile, } list.value = list.value.map(item => item.id === id ? deleteDraftItem! : item) as DraftItem[] @@ -94,14 +94,14 @@ export function useDraftBase( } else { // TODO: check if gh file has been updated - const githubFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile + const remoteFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile deleteDraftItem = { id, fsPath, status: DraftStatus.Deleted, original: originalDbItem, - githubFile, + remoteFile, } list.value.push(deleteDraftItem) diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts index 7d01e4e5..c83ba699 100644 --- a/src/app/src/composables/useDraftMedias.ts +++ b/src/app/src/composables/useDraftMedias.ts @@ -41,7 +41,7 @@ export const useDraftMedias = createSharedComposable((host: StudioHost, git: Ret return { id: joinURL(TreeRootId.Media, fsPath), fsPath, - githubFile: undefined, + remoteFile: undefined, status: DraftStatus.Created, modified: { id: joinURL(TreeRootId.Media, fsPath), diff --git a/src/app/src/types/draft.ts b/src/app/src/types/draft.ts index b03c2b77..b83078c7 100644 --- a/src/app/src/types/draft.ts +++ b/src/app/src/types/draft.ts @@ -19,7 +19,7 @@ export interface DraftItem { fsPath: string // file path in content directory status: DraftStatus // status - githubFile?: GithubFile // file fetched on gh + remoteFile?: GithubFile original?: T modified?: T /** diff --git a/src/app/src/utils/draft.ts b/src/app/src/utils/draft.ts index 47693b19..09bc9a5d 100644 --- a/src/app/src/utils/draft.ts +++ b/src/app/src/utils/draft.ts @@ -14,20 +14,20 @@ export async function checkConflict(draftItem: DraftItem => ({ - fetchFile: vi.fn().mockResolvedValue(githubFile || createMockGithubFile()), +export const createMockGit = (remoteFile?: GithubFile): ReturnType => ({ + fetchFile: vi.fn().mockResolvedValue(remoteFile || createMockGithubFile()), } as never) export const createMockGithubFile = (overrides?: Partial): GithubFile => ({ + provider: 'github', path: 'content/document.md', name: 'document.md', content: 'Test content', From f5534c76d7002d97a965b465af9a1a2350ef6938 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Mon, 10 Nov 2025 09:35:06 +0100 Subject: [PATCH 05/17] fix(git): useDevelopmentGit as GitProvider return-type --- src/app/src/composables/useGit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/src/composables/useGit.ts b/src/app/src/composables/useGit.ts index a41e8f07..cb6bec20 100644 --- a/src/app/src/composables/useGit.ts +++ b/src/app/src/composables/useGit.ts @@ -5,7 +5,7 @@ import { DraftStatus } from '../types/draft' import { joinURL } from 'ufo' -export const useDevelopmentGit = (_options: GitOptions) => { +export const useDevelopmentGit = (_options: GitOptions): GitProvider => { return { fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), commitFiles: (_files: RawFile[], _message: string): Promise => Promise.resolve(null), @@ -13,7 +13,7 @@ export const useDevelopmentGit = (_options: GitOptions) => { getBranchUrl: () => '', getCommitUrl: () => '', getContentRootDirUrl: () => '', - getRepositoryInfo: () => ({ owner: '', repo: '', branch: '', provider: 'github' }), + getRepositoryInfo: () => ({ owner: '', repo: '', branch: '', provider: 'github' as const }), } } From 442271624d1c9b046bf93d9b83090c6b3d8799c1 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Mon, 10 Nov 2025 09:35:27 +0100 Subject: [PATCH 06/17] fix(studio): missing renaming githubFile --- src/app/src/pages/media.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/src/pages/media.vue b/src/app/src/pages/media.vue index 8f1bf7f3..5b28ca01 100644 --- a/src/app/src/pages/media.vue +++ b/src/app/src/pages/media.vue @@ -64,7 +64,7 @@ async function onFileDrop(event: DragEvent) {
Date: Mon, 10 Nov 2025 09:36:51 +0100 Subject: [PATCH 07/17] fix(content): handle content-type base64 or utf-8 decoding --- src/app/src/components/content/ContentCardReview.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/src/components/content/ContentCardReview.vue b/src/app/src/components/content/ContentCardReview.vue index b1acd308..917a3b57 100644 --- a/src/app/src/components/content/ContentCardReview.vue +++ b/src/app/src/components/content/ContentCardReview.vue @@ -90,7 +90,11 @@ async function initializeEditor() { isLoadingContent.value = true const localOriginal = props.draftItem.original ? await generateContentFromDocument(props.draftItem.original as DatabaseItem) : null - const gitHubOriginal = props.draftItem.remoteFile?.content ? fromBase64ToUTF8(props.draftItem.remoteFile.content) : null + const gitHubOriginal = props.draftItem.remoteFile?.content + ? (props.draftItem.remoteFile.encoding === 'base64' + ? fromBase64ToUTF8(props.draftItem.remoteFile.content) + : props.draftItem.remoteFile.content) + : null const modified = props.draftItem.modified ? await generateContentFromDocument(props.draftItem.modified as DatabasePageItem) : null isAutomaticFormattingDetected.value = !isEqual(localOriginal, gitHubOriginal) From fea4c506bb70946b6a8983e4f97e5929a69fac85 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Mon, 10 Nov 2025 10:03:47 +0100 Subject: [PATCH 08/17] fix(git): missing provider on file fetched for GitHub provider --- src/app/src/composables/useGit.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/src/composables/useGit.ts b/src/app/src/composables/useGit.ts index cb6bec20..842a43b2 100644 --- a/src/app/src/composables/useGit.ts +++ b/src/app/src/composables/useGit.ts @@ -42,7 +42,12 @@ function createGitHubProvider(options: GitOptions): GitProvider { } try { - const ghFile: GitFile = await $api(`/contents/${path}?ref=${branch}`) + const ghResponse = await $api(`/contents/${path}?ref=${branch}`) + const ghFile: GitFile = { + ...ghResponse, + provider: 'github' as const, + } + if (cached) { gitFiles[path] = ghFile } From 91f6befe8ed23991463f74fc40526fa3e738f0f5 Mon Sep 17 00:00:00 2001 From: Thomas Cazade Date: Mon, 10 Nov 2025 10:12:08 +0100 Subject: [PATCH 09/17] refactor: more renaming from github to remote --- .../components/content/ContentCardReview.vue | 8 +++--- .../content/ContentEditorConflict.vue | 6 ++--- src/app/src/components/media/MediaEditor.vue | 8 +++--- .../src/components/media/MediaEditorImage.vue | 26 +++++++++---------- src/app/src/composables/useDraftBase.ts | 10 +++---- src/app/src/pages/media.vue | 2 +- src/app/src/types/draft.ts | 6 ++--- src/app/src/utils/draft.ts | 14 +++++----- 8 files changed, 40 insertions(+), 40 deletions(-) diff --git a/src/app/src/components/content/ContentCardReview.vue b/src/app/src/components/content/ContentCardReview.vue index 917a3b57..0b4d87ca 100644 --- a/src/app/src/components/content/ContentCardReview.vue +++ b/src/app/src/components/content/ContentCardReview.vue @@ -90,21 +90,21 @@ async function initializeEditor() { isLoadingContent.value = true const localOriginal = props.draftItem.original ? await generateContentFromDocument(props.draftItem.original as DatabaseItem) : null - const gitHubOriginal = props.draftItem.remoteFile?.content + const remoteOriginal = props.draftItem.remoteFile?.content ? (props.draftItem.remoteFile.encoding === 'base64' ? fromBase64ToUTF8(props.draftItem.remoteFile.content) : props.draftItem.remoteFile.content) : null const modified = props.draftItem.modified ? await generateContentFromDocument(props.draftItem.modified as DatabasePageItem) : null - isAutomaticFormattingDetected.value = !isEqual(localOriginal, gitHubOriginal) + isAutomaticFormattingDetected.value = !isEqual(localOriginal, remoteOriginal) // Wait for DOM to update before initializing Monaco await nextTick() if (props.draftItem.status === DraftStatus.Updated) { useMonacoDiff(diffEditorRef, { - original: gitHubOriginal!, + original: remoteOriginal!, modified: modified!, language: language.value, colorMode: ui.colorMode, @@ -118,7 +118,7 @@ async function initializeEditor() { else if ([DraftStatus.Created, DraftStatus.Deleted].includes(props.draftItem.status)) { useMonaco(editorRef, { language, - initialContent: modified! || gitHubOriginal!, + initialContent: modified! || remoteOriginal!, readOnly: true, colorMode: ui.colorMode, }) diff --git a/src/app/src/components/content/ContentEditorConflict.vue b/src/app/src/components/content/ContentEditorConflict.vue index fc668f9e..b706e299 100644 --- a/src/app/src/components/content/ContentEditorConflict.vue +++ b/src/app/src/components/content/ContentEditorConflict.vue @@ -19,7 +19,7 @@ const diffEditorRef = ref() const conflict = computed(() => props.draftItem.conflict!) const repositoryInfo = computed(() => git.getRepositoryInfo()) -const fileGitHubUrl = computed(() => joinURL(git.getContentRootDirUrl(), props.draftItem.fsPath)) +const fileRemoteUrl = computed(() => joinURL(git.getContentRootDirUrl(), props.draftItem.fsPath)) const language = computed(() => { switch (props.draftItem.fsPath.split('.').pop()) { @@ -36,7 +36,7 @@ const language = computed(() => { }) useMonacoDiff(diffEditorRef, { - original: conflict.value?.githubContent || '', + original: conflict.value?.remoteContent || '', modified: conflict.value?.localContent || '', language: language.value, colorMode: ui.colorMode, @@ -108,7 +108,7 @@ useMonacoDiff(diffEditorRef, {
import { computed, type PropType } from 'vue' -import type { MediaItem, DraftStatus, GithubFile } from '../../types' +import type { MediaItem, DraftStatus, GitFile } from '../../types' import { isImageFile, isVideoFile, isAudioFile } from '../../utils/file' const props = defineProps({ @@ -8,8 +8,8 @@ const props = defineProps({ type: Object as PropType, required: true, }, - githubFile: { - type: Object as PropType, + remoteFile: { + type: Object as PropType, default: null, }, status: { @@ -28,7 +28,7 @@ const isAudio = computed(() => isAudioFile(props.mediaItem?.path || '')) import { ref, computed, onMounted } from 'vue' import { formatBytes, getFileExtension } from '../../utils/file' -import type { MediaItem, GithubFile } from '../../types' +import type { MediaItem, GitFile } from '../../types' import type { PropType } from 'vue' import { useStudio } from '../../composables/useStudio' import { joinURL } from 'ufo' @@ -11,8 +11,8 @@ const props = defineProps({ type: Object as PropType, required: true, }, - githubFile: { - type: Object as PropType, + remoteFile: { + type: Object as PropType, default: null, }, }) @@ -44,8 +44,8 @@ const imageInfo = computed(() => { { label: 'Type', value: fileExtension.value }, ] - if (props.githubFile) { - info.push({ label: 'Size', value: formatBytes(props.githubFile.size) }) + if (props.remoteFile) { + info.push({ label: 'Size', value: formatBytes(props.remoteFile.size) }) } return info @@ -61,12 +61,12 @@ const previewBackground = computed(() => { }) const markdownCode = computed(() => { - const name = props.githubFile?.name || 'image' + const name = props.remoteFile?.name || 'image' return `![${name}](${props.mediaItem.path})` }) -const githubPath = computed(() => { - return joinURL(git.getBranchUrl(), props.githubFile.path!) +const remotePath = computed(() => { + return joinURL(git.getBranchUrl(), props.remoteFile.path!) }) @@ -100,7 +100,7 @@ const githubPath = computed(() => {
@@ -111,7 +111,7 @@ const githubPath = computed(() => { File name

- {{ githubFile.name }} + {{ remoteFile.name }}

@@ -132,11 +132,11 @@ const githubPath = computed(() => {
- +
{ GitHub path

- {{ githubFile.path }} + {{ remoteFile.path }}

diff --git a/src/app/src/composables/useDraftBase.ts b/src/app/src/composables/useDraftBase.ts index 8d26aa07..6940bcb7 100644 --- a/src/app/src/composables/useDraftBase.ts +++ b/src/app/src/composables/useDraftBase.ts @@ -1,6 +1,6 @@ import type { Storage } from 'unstorage' import { joinURL } from 'ufo' -import type { DraftItem, StudioHost, GithubFile, DatabaseItem, MediaItem } from '../types' +import type { DraftItem, StudioHost, GitFile, DatabaseItem, MediaItem } from '../types' import { DraftStatus } from '../types/draft' import { checkConflict, findDescendantsFromId, getDraftStatus } from '../utils/draft' import type { useGit } from './useGit' @@ -17,7 +17,7 @@ export function useDraftBase( const list = ref[]>([]) const current = ref | null>(null) - const ghPathPrefix = type === 'media' ? 'public' : 'content' + const remotePathPrefix = type === 'media' ? 'public' : 'content' const hostDb = type === 'media' ? host.media : host.document const hookName = `studio:draft:${type}:updated` as const @@ -34,7 +34,7 @@ export function useDraftBase( } const fsPath = hostDb.getFileSystemPath(item.id) - const remoteFile = await git.fetchFile(joinURL(ghPathPrefix, fsPath), { cached: true }) as GithubFile + const remoteFile = await git.fetchFile(joinURL(remotePathPrefix, fsPath), { cached: true }) as GitFile const draftItem: DraftItem = { id: item.id, @@ -93,8 +93,8 @@ export function useDraftBase( } } else { - // TODO: check if gh file has been updated - const remoteFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GithubFile + // TODO: check if remote file has been updated + const remoteFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile deleteDraftItem = { id, diff --git a/src/app/src/pages/media.vue b/src/app/src/pages/media.vue index 5b28ca01..4b01eca5 100644 --- a/src/app/src/pages/media.vue +++ b/src/app/src/pages/media.vue @@ -64,7 +64,7 @@ async function onFileDrop(event: DragEvent) {
{ fsPath: string // file path in content directory status: DraftStatus // status - remoteFile?: GithubFile + remoteFile?: GitFile original?: T modified?: T /** diff --git a/src/app/src/utils/draft.ts b/src/app/src/utils/draft.ts index 09bc9a5d..b1c25c0b 100644 --- a/src/app/src/utils/draft.ts +++ b/src/app/src/utils/draft.ts @@ -16,30 +16,30 @@ export async function checkConflict(draftItem: DraftItem Date: Mon, 10 Nov 2025 10:24:44 +0100 Subject: [PATCH 10/17] fix(studio): properly display git-provider related info on UI (icon, name) --- src/app/src/components/AppFooter.vue | 4 +- .../content/ContentEditorConflict.vue | 12 +++--- .../src/components/media/MediaEditorImage.vue | 6 ++- src/app/src/composables/useGitProviderIcon.ts | 37 +++++++++++++++++++ src/app/src/pages/error.vue | 6 ++- src/app/src/pages/success.vue | 4 +- src/app/src/types/user.ts | 4 +- 7 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 src/app/src/composables/useGitProviderIcon.ts diff --git a/src/app/src/components/AppFooter.vue b/src/app/src/components/AppFooter.vue index 33c0dc97..25ad085c 100644 --- a/src/app/src/components/AppFooter.vue +++ b/src/app/src/components/AppFooter.vue @@ -2,10 +2,12 @@ import { computed } from 'vue' import { useStudio } from '../composables/useStudio' import { useStudioState } from '../composables/useStudioState' +import { useGitProviderIcon } from '../composables/useGitProviderIcon' const { ui, host, git } = useStudio() const { preferences, updatePreference, unsetActiveLocation } = useStudioState() const user = host.user.get() +const { icon: gitProviderIcon } = useGitProviderIcon() // const showTechnicalMode = computed({ // get: () => preferences.value.showTechnicalMode, @@ -23,7 +25,7 @@ const userMenuItems = computed(() => [ repositoryUrl.value ? { label: `${host.repository.owner}/${host.repository.repo}`, - icon: 'i-simple-icons:github', + icon: gitProviderIcon.value, to: repositoryUrl.value, target: '_blank', } diff --git a/src/app/src/components/content/ContentEditorConflict.vue b/src/app/src/components/content/ContentEditorConflict.vue index b706e299..629c99e9 100644 --- a/src/app/src/components/content/ContentEditorConflict.vue +++ b/src/app/src/components/content/ContentEditorConflict.vue @@ -3,6 +3,7 @@ import { ref, computed, type PropType } from 'vue' import type { ContentConflict, DraftItem } from '../../types' import { useMonacoDiff } from '../../composables/useMonacoDiff' import { useStudio } from '../../composables/useStudio' +import { useGitProviderIcon } from '../../composables/useGitProviderIcon' import { ContentFileExtension } from '../../types' import { joinURL } from 'ufo' @@ -14,6 +15,7 @@ const props = defineProps({ }) const { ui, git } = useStudio() +const { icon: gitProviderIcon, providerName } = useGitProviderIcon() const diffEditorRef = ref() @@ -49,7 +51,7 @@ useMonacoDiff(diffEditorRef, {

@@ -60,7 +62,7 @@ useMonacoDiff(diffEditorRef, {
Repository @@ -118,7 +120,7 @@ useMonacoDiff(diffEditorRef, {

- The content on GitHub differs from your website version. Ensure your latest changes are deployed and refresh the page. + The content on {{ providerName }} differs from your website version. Ensure your latest changes are deployed and refresh the page.

@@ -127,10 +129,10 @@ useMonacoDiff(diffEditorRef, {
- GitHub + {{ providerName }}
(null) const imageDimensions = ref({ width: 0, height: 0 }) @@ -140,10 +142,10 @@ const remotePath = computed(() => {
- GitHub path + {{ providerName }} path

{{ remoteFile.path }} diff --git a/src/app/src/composables/useGitProviderIcon.ts b/src/app/src/composables/useGitProviderIcon.ts new file mode 100644 index 00000000..72e26000 --- /dev/null +++ b/src/app/src/composables/useGitProviderIcon.ts @@ -0,0 +1,37 @@ +import { computed } from 'vue' +import { useStudio } from './useStudio' +import type { GitProviderType } from '../types' + +export function useGitProviderIcon() { + const { host } = useStudio() + + const provider = computed(() => host.repository.provider) + + const icon = computed(() => { + switch (provider.value) { + case 'github': + return 'i-simple-icons:github' + case 'gitlab': + return 'i-simple-icons:gitlab' + default: + return 'i-simple-icons:git' + } + }) + + const providerName = computed(() => { + switch (provider.value) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + default: + return 'Git' + } + }) + + return { + provider, + icon, + providerName, + } +} diff --git a/src/app/src/pages/error.vue b/src/app/src/pages/error.vue index de678f4d..9853087a 100644 --- a/src/app/src/pages/error.vue +++ b/src/app/src/pages/error.vue @@ -2,10 +2,12 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useStudio } from '../composables/useStudio' +import { useGitProviderIcon } from '../composables/useGitProviderIcon' const route = useRoute() const router = useRouter() const { git } = useStudio() +const { icon: gitProviderIcon, providerName } = useGitProviderIcon() const errorMessage = computed(() => { return (route.query.error as string) || 'An unknown error occurred' @@ -47,7 +49,7 @@ function retry() { of { of Date: Fri, 14 Nov 2025 17:19:23 +0100 Subject: [PATCH 11/17] refactor: providers utils folder + one single useGitProvider compoable + create empty provider for dev mode --- src/app/src/components/AppFooter.vue | 6 +- .../content/ContentEditorConflict.vue | 18 +- .../src/components/media/MediaEditorImage.vue | 8 +- src/app/src/composables/useContext.ts | 6 +- src/app/src/composables/useDraftBase.ts | 10 +- src/app/src/composables/useDraftDocuments.ts | 4 +- src/app/src/composables/useDraftMedias.ts | 4 +- src/app/src/composables/useGit.ts | 379 ------------------ src/app/src/composables/useGitProvider.ts | 46 +++ src/app/src/composables/useGitProviderIcon.ts | 37 -- src/app/src/composables/useStudio.ts | 4 +- src/app/src/pages/error.vue | 12 +- src/app/src/pages/success.vue | 10 +- src/app/src/types/git.ts | 8 +- src/app/src/utils/providers/github.ts | 190 +++++++++ src/app/src/utils/providers/gitlab.ts | 168 ++++++++ src/app/src/utils/providers/index.ts | 3 + src/app/src/utils/providers/null.ts | 17 + src/app/test/integration/actions.test.ts | 4 +- src/app/test/mocks/git.ts | 18 +- src/module/src/runtime/utils/activation.ts | 4 +- 21 files changed, 482 insertions(+), 474 deletions(-) delete mode 100644 src/app/src/composables/useGit.ts create mode 100644 src/app/src/composables/useGitProvider.ts delete mode 100644 src/app/src/composables/useGitProviderIcon.ts create mode 100644 src/app/src/utils/providers/github.ts create mode 100644 src/app/src/utils/providers/gitlab.ts create mode 100644 src/app/src/utils/providers/index.ts create mode 100644 src/app/src/utils/providers/null.ts diff --git a/src/app/src/components/AppFooter.vue b/src/app/src/components/AppFooter.vue index b8936d41..c2729c75 100644 --- a/src/app/src/components/AppFooter.vue +++ b/src/app/src/components/AppFooter.vue @@ -3,13 +3,11 @@ import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { useStudio } from '../composables/useStudio' import { useStudioState } from '../composables/useStudioState' -import { useGitProviderIcon } from '../composables/useGitProviderIcon' import type { DropdownMenuItem } from '@nuxt/ui/runtime/components/DropdownMenu.vue.d.ts' const { ui, host, git } = useStudio() const { devMode, preferences, updatePreference, unsetActiveLocation } = useStudioState() const user = host.user.get() -const { icon: gitProviderIcon } = useGitProviderIcon() const { t } = useI18n() // const showTechnicalMode = computed({ @@ -19,7 +17,7 @@ const { t } = useI18n() // }, // }) -const repositoryUrl = computed(() => git.getBranchUrl()) +const repositoryUrl = computed(() => git.api.getBranchUrl()) const userMenuItems = computed(() => [ repositoryUrl.value ? [ @@ -28,7 +26,7 @@ const userMenuItems = computed(() => [ // } { label: `${host.repository.owner}/${host.repository.repo}`, - icon: gitProviderIcon.value, + icon: git.icon, to: repositoryUrl.value, target: '_blank', }, diff --git a/src/app/src/components/content/ContentEditorConflict.vue b/src/app/src/components/content/ContentEditorConflict.vue index 2cf04bc9..16869204 100644 --- a/src/app/src/components/content/ContentEditorConflict.vue +++ b/src/app/src/components/content/ContentEditorConflict.vue @@ -3,7 +3,6 @@ import { ref, computed, type PropType } from 'vue' import type { ContentConflict, DraftItem } from '../../types' import { useMonacoDiff } from '../../composables/useMonacoDiff' import { useStudio } from '../../composables/useStudio' -import { useGitProviderIcon } from '../../composables/useGitProviderIcon' import { ContentFileExtension } from '../../types' import { joinURL } from 'ufo' @@ -15,13 +14,12 @@ const props = defineProps({ }) const { ui, git } = useStudio() -const { icon: gitProviderIcon, providerName } = useGitProviderIcon() const diffEditorRef = ref() const conflict = computed(() => props.draftItem.conflict!) -const repositoryInfo = computed(() => git.getRepositoryInfo()) -const fileRemoteUrl = computed(() => joinURL(git.getContentRootDirUrl(), props.draftItem.fsPath)) +const repositoryInfo = computed(() => git.api.getRepositoryInfo()) +const fileRemoteUrl = computed(() => joinURL(git.api.getContentRootDirUrl(), props.draftItem.fsPath)) const language = computed(() => { switch (props.draftItem.fsPath.split('.').pop()) { @@ -62,7 +60,7 @@ useMonacoDiff(diffEditorRef, {

{{ $t('studio.conflict.repository') }} @@ -70,7 +68,7 @@ useMonacoDiff(diffEditorRef, {

- {{ $t('studio.conflict.description', providerName) }} + {{ $t('studio.conflict.description', git.name) }}

@@ -129,10 +127,10 @@ useMonacoDiff(diffEditorRef, {
- {{ providerName }} + {{ git.name }}
(null) @@ -70,7 +68,7 @@ const markdownCode = computed(() => { }) const remotePath = computed(() => { - return joinURL(git.getBranchUrl(), props.remoteFile.path!) + return joinURL(git.api.getBranchUrl(), props.remoteFile.path!) }) @@ -144,10 +142,10 @@ const remotePath = computed(() => {
- {{ $t('studio.media.providerPath', providerName) }} + {{ $t('studio.media.providerPath', git.name) }}

{{ remoteFile.path }} diff --git a/src/app/src/composables/useContext.ts b/src/app/src/composables/useContext.ts index 337d363a..ecc93fa1 100644 --- a/src/app/src/composables/useContext.ts +++ b/src/app/src/composables/useContext.ts @@ -19,7 +19,7 @@ import type { import { VirtualMediaCollectionName, generateStemFromFsPath } from '../utils/media' import { oneStepActions, STUDIO_ITEM_ACTION_DEFINITIONS, twoStepActions, STUDIO_BRANCH_ACTION_DEFINITIONS } from '../utils/context' import type { useTree } from './useTree' -import type { useGit } from './useGit' +import type { useGitProvider } from './useGitProvider' import type { useDraftMedias } from './useDraftMedias' import { useRoute, useRouter } from 'vue-router' import { findDescendantsFileItemsFromFsPath } from '../utils/tree' @@ -28,7 +28,7 @@ import { upperFirst } from 'scule' export const useContext = createSharedComposable(( host: StudioHost, - git: ReturnType, + git: ReturnType, documentTree: ReturnType, mediaTree: ReturnType, ) => { @@ -221,7 +221,7 @@ export const useContext = createSharedComposable(( const { commitMessage } = params const documentFiles = await documentTree.draft.listAsRawFiles() const mediaFiles = await mediaTree.draft.listAsRawFiles() - await git.commitFiles([...documentFiles, ...mediaFiles], commitMessage) + await git.api.commitFiles([...documentFiles, ...mediaFiles], commitMessage) // @ts-expect-error params is null await itemActionHandler[StudioItemActionId.RevertAllItems]() diff --git a/src/app/src/composables/useDraftBase.ts b/src/app/src/composables/useDraftBase.ts index eb0519d2..e6482276 100644 --- a/src/app/src/composables/useDraftBase.ts +++ b/src/app/src/composables/useDraftBase.ts @@ -4,7 +4,7 @@ import type { DraftItem, StudioHost, GitFile, DatabaseItem, MediaItem, BaseItem import { ContentFileExtension } from '../types' import { DraftStatus } from '../types/draft' import { checkConflict, findDescendantsFromFsPath } from '../utils/draft' -import type { useGit } from './useGit' +import type { useGitProvider } from './useGitProvider' import { useHooks } from './useHooks' import { ref } from 'vue' import { useStudioState } from './useStudioState' @@ -12,7 +12,7 @@ import { useStudioState } from './useStudioState' export function useDraftBase( type: 'media' | 'document', host: StudioHost, - git: ReturnType, + git: ReturnType, storage: Storage>, ) { const isLoading = ref(false) @@ -37,7 +37,7 @@ export function useDraftBase( throw new Error(`Draft file already exists for document at ${fsPath}`) } - const remoteFile = await git.fetchFile(joinURL(remotePathPrefix, fsPath), { cached: true }) as GitFile + const remoteFile = await git.api.fetchFile(joinURL(remotePathPrefix, fsPath), { cached: true }) as GitFile const draftItem: DraftItem = { fsPath, @@ -84,7 +84,7 @@ export function useDraftBase( } else { // TODO: check if remote file has been updated - const remoteFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile + const remoteFile = await git.api.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile deleteDraftItem = { fsPath: existingDraftItem.fsPath, @@ -98,7 +98,7 @@ export function useDraftBase( } else { // TODO: check if gh file has been updated - const remoteFile = await git.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile + const remoteFile = await git.api.fetchFile(joinURL('content', fsPath), { cached: true }) as GitFile deleteDraftItem = { fsPath, diff --git a/src/app/src/composables/useDraftDocuments.ts b/src/app/src/composables/useDraftDocuments.ts index 8794dfa1..3e1a73fa 100644 --- a/src/app/src/composables/useDraftDocuments.ts +++ b/src/app/src/composables/useDraftDocuments.ts @@ -1,6 +1,6 @@ import type { DatabaseItem, DraftItem, StudioHost, RawFile } from '../types' import { DraftStatus } from '../types/draft' -import type { useGit } from './useGit' +import type { useGitProvider } from './useGitProvider' import { createSharedComposable } from '@vueuse/core' import { useHooks } from './useHooks' import { joinURL } from 'ufo' @@ -8,7 +8,7 @@ import { documentStorage as storage } from '../utils/storage' import { getFileExtension } from '../utils/file' import { useDraftBase } from './useDraftBase' -export const useDraftDocuments = createSharedComposable((host: StudioHost, git: ReturnType) => { +export const useDraftDocuments = createSharedComposable((host: StudioHost, git: ReturnType) => { const { isLoading, list, diff --git a/src/app/src/composables/useDraftMedias.ts b/src/app/src/composables/useDraftMedias.ts index 6de0338d..8bbdb94e 100644 --- a/src/app/src/composables/useDraftMedias.ts +++ b/src/app/src/composables/useDraftMedias.ts @@ -2,7 +2,7 @@ import { joinURL, withLeadingSlash } from 'ufo' import type { DraftItem, StudioHost, MediaItem, RawFile } from '../types' import { VirtualMediaCollectionName, generateStemFromFsPath } from '../utils/media' import { DraftStatus } from '../types/draft' -import type { useGit } from './useGit' +import type { useGitProvider } from './useGitProvider' import { createSharedComposable } from '@vueuse/core' import { useDraftBase } from './useDraftBase' import { mediaStorage as storage } from '../utils/storage' @@ -11,7 +11,7 @@ import { useHooks } from './useHooks' const hooks = useHooks() -export const useDraftMedias = createSharedComposable((host: StudioHost, git: ReturnType) => { +export const useDraftMedias = createSharedComposable((host: StudioHost, git: ReturnType) => { const { isLoading, list, diff --git a/src/app/src/composables/useGit.ts b/src/app/src/composables/useGit.ts deleted file mode 100644 index 842a43b2..00000000 --- a/src/app/src/composables/useGit.ts +++ /dev/null @@ -1,379 +0,0 @@ -import { ofetch } from 'ofetch' -import { createSharedComposable } from '@vueuse/core' -import type { RawFile, GitFile, GitOptions, CommitFilesOptions, CommitResult, GitProvider } from '../types' -import { DraftStatus } from '../types/draft' - -import { joinURL } from 'ufo' - -export const useDevelopmentGit = (_options: GitOptions): GitProvider => { - return { - fetchFile: (_path: string, _options: { cached?: boolean } = {}): Promise => Promise.resolve(null), - commitFiles: (_files: RawFile[], _message: string): Promise => Promise.resolve(null), - getRepositoryUrl: () => '', - getBranchUrl: () => '', - getCommitUrl: () => '', - getContentRootDirUrl: () => '', - getRepositoryInfo: () => ({ owner: '', repo: '', branch: '', provider: 'github' as const }), - } -} - -function createGitHubProvider(options: GitOptions): GitProvider { - const { owner, repo, token, branch, rootDir, authorName, authorEmail } = options - const gitFiles: Record = {} - - // Support both token formats: "token {token}" for classic PATs, "Bearer {token}" for OAuth/fine-grained PATs - const authHeader = token.startsWith('ghp_') ? `token ${token}` : `Bearer ${token}` - - const $api = ofetch.create({ - baseURL: `https://api.github.com/repos/${owner}/${repo}`, - headers: { - Authorization: authHeader, - Accept: 'application/vnd.github.v3+json', - }, - }) - - async function fetchFile(path: string, { cached = false }: { cached?: boolean } = {}): Promise { - path = joinURL(rootDir, path) - if (cached) { - const file = gitFiles[path] - if (file) { - return file - } - } - - try { - const ghResponse = await $api(`/contents/${path}?ref=${branch}`) - const ghFile: GitFile = { - ...ghResponse, - provider: 'github' as const, - } - - if (cached) { - gitFiles[path] = ghFile - } - return ghFile - } - catch (error) { - // Handle different types of errors gracefully - if ((error as { status?: number }).status === 404) { - console.warn(`File not found on GitHub: ${path}`) - return null - } - - console.error(`Failed to fetch file from GitHub: ${path}`, error) - - // For development, show alert. In production, you might want to use a toast notification - if (process.env.NODE_ENV === 'development') { - alert(`Failed to fetch file: ${path}\n${(error as { message?: string }).message || error}`) - } - - return null - } - } - - function commitFiles(files: RawFile[], message: string): Promise { - if (!token) { - return Promise.resolve(null) - } - - files = files - .filter(file => file.status !== DraftStatus.Pristine) - .map(file => ({ ...file, path: joinURL(rootDir, file.path) })) - - return commitFilesToGitHub({ - owner, - repo, - branch, - files, - message, - authorName, - authorEmail, - }) - } - - async function commitFilesToGitHub({ owner, repo, branch, files, message, authorName, authorEmail }: CommitFilesOptions) { - // Get latest commit SHA - const refData = await $api(`/git/refs/heads/${branch}`) - const latestCommitSha = refData.object.sha - - // Get base tree SHA - const commitData = await $api(`/git/commits/${latestCommitSha}`) - const baseTreeSha = commitData.tree.sha - - // Create blobs and prepare tree - const tree = [] - for (const file of files) { - if (file.status === DraftStatus.Deleted) { - // For deleted files, set sha to null to remove them from the tree - tree.push({ - path: file.path, - mode: '100644', - type: 'blob', - sha: null, - }) - } - else { - // For new/modified files, create blob and use its sha - const blobData = await $api(`/git/blobs`, { - method: 'POST', - body: JSON.stringify({ - content: file.content, - encoding: file.encoding, - }), - }) - tree.push({ - path: file.path, - mode: '100644', - type: 'blob', - sha: blobData.sha, - }) - } - } - - // Create new tree - const treeData = await $api(`/git/trees`, { - method: 'POST', - body: JSON.stringify({ - base_tree: baseTreeSha, - tree, - }), - }) - - // Create new commit - const newCommit = await $api(`/git/commits`, { - method: 'POST', - body: JSON.stringify({ - message, - tree: treeData.sha, - parents: [latestCommitSha], - author: { - name: authorName, - email: authorEmail, - date: new Date().toISOString(), - }, - }), - }) - - // Update branch ref - await $api(`/git/refs/heads/${branch}`, { - method: 'PATCH', - body: JSON.stringify({ sha: newCommit.sha }), - }) - - return { - success: true, - commitSha: newCommit.sha, - url: `https://github.com/${owner}/${repo}/commit/${newCommit.sha}`, - } - } - - function getRepositoryUrl() { - return `https://github.com/${owner}/${repo}` - } - - function getBranchUrl() { - return `https://github.com/${owner}/${repo}/tree/${branch}` - } - - function getCommitUrl(sha: string) { - return `https://github.com/${owner}/${repo}/commit/${sha}` - } - - function getContentRootDirUrl() { - return `https://github.com/${owner}/${repo}/tree/${branch}/${rootDir}/content` - } - - function getRepositoryInfo() { - return { - owner, - repo, - branch, - provider: 'github' as const, - } - } - - return { - fetchFile, - commitFiles, - getRepositoryUrl, - getBranchUrl, - getCommitUrl, - getContentRootDirUrl, - getRepositoryInfo, - } -} - -function createGitLabProvider(options: GitOptions): GitProvider { - const { owner, repo, token, branch, rootDir, authorName, authorEmail, instanceUrl = 'https://gitlab.com' } = options - const gitFiles: Record = {} - - // GitLab uses project path (namespace/project) encoded as project ID - const projectPath = encodeURIComponent(`${owner}/${repo}`) - const baseURL = `${instanceUrl}/api/v4` - - const $api = ofetch.create({ - baseURL: `${baseURL}/projects/${projectPath}`, - headers: { - Authorization: `Bearer ${token}`, - }, - }) - - async function fetchFile(path: string, { cached = false }: { cached?: boolean } = {}): Promise { - path = joinURL(rootDir, path) - if (cached) { - const file = gitFiles[path] - if (file) { - return file - } - } - - try { - const encodedPath = encodeURIComponent(path) - // GitLab API returns base64-encoded content when using /repository/files endpoint (without /raw) - const fileMetadata = await $api(`/repository/files/${encodedPath}?ref=${branch}`) - - const gitFile: GitFile = { - name: path.split('/').pop() || path, - path, - sha: fileMetadata.blob_id, - size: fileMetadata.size, - url: fileMetadata.file_path, - content: fileMetadata.content, - encoding: 'base64' as const, - provider: 'gitlab' as const, - } - - if (cached) { - gitFiles[path] = gitFile - } - return gitFile - } - catch (error) { - // Handle different types of errors gracefully - if ((error as { status?: number }).status === 404) { - console.warn(`File not found on GitLab: ${path}`) - return null - } - - console.error(`Failed to fetch file from GitLab: ${path}`, error) - - // For development, show alert. In production, you might want to use a toast notification - if (process.env.NODE_ENV === 'development') { - alert(`Failed to fetch file: ${path}\n${(error as { message?: string }).message || error}`) - } - - return null - } - } - - function commitFiles(files: RawFile[], message: string): Promise { - if (!token) { - return Promise.resolve(null) - } - - files = files - .filter(file => file.status !== DraftStatus.Pristine) - .map(file => ({ ...file, path: joinURL(rootDir, file.path) })) - - return commitFilesToGitLab({ - owner, - repo, - branch, - files, - message, - authorName, - authorEmail, - }) - } - - async function commitFilesToGitLab({ branch, files, message, authorName, authorEmail }: CommitFilesOptions) { - // GitLab uses a single commits API with actions - const actions = files.map((file) => { - if (file.status === DraftStatus.Deleted) { - return { - action: 'delete', - file_path: file.path, - } - } - else if (file.status === DraftStatus.Created) { - return { - action: 'create', - file_path: file.path, - content: file.content, - encoding: file.encoding === 'base64' ? 'base64' : 'text', - } - } - else { - return { - action: 'update', - file_path: file.path, - content: file.content, - encoding: file.encoding === 'base64' ? 'base64' : 'text', - } - } - }) - - const commitData = await $api(`/repository/commits`, { - method: 'POST', - body: { - branch, - commit_message: message, - actions, - author_name: authorName, - author_email: authorEmail, - }, - }) - - return { - success: true, - commitSha: commitData.id, - url: `${instanceUrl}/${owner}/${repo}/-/commit/${commitData.id}`, - } - } - - function getRepositoryUrl() { - return `${instanceUrl}/${owner}/${repo}` - } - - function getBranchUrl() { - return `${instanceUrl}/${owner}/${repo}/-/tree/${branch}` - } - - function getCommitUrl(sha: string) { - return `${instanceUrl}/${owner}/${repo}/-/commit/${sha}` - } - - function getContentRootDirUrl() { - return `${instanceUrl}/${owner}/${repo}/-/tree/${branch}/${rootDir}/content` - } - - function getRepositoryInfo() { - return { - owner, - repo, - branch, - provider: 'gitlab' as const, - } - } - - return { - fetchFile, - commitFiles, - getRepositoryUrl, - getBranchUrl, - getCommitUrl, - getContentRootDirUrl, - getRepositoryInfo, - } -} - -export const useGit = createSharedComposable((options: GitOptions): GitProvider => { - const provider = options.provider || 'github' - - if (provider === 'gitlab') { - return createGitLabProvider(options) - } - else { - return createGitHubProvider(options) - } -}) diff --git a/src/app/src/composables/useGitProvider.ts b/src/app/src/composables/useGitProvider.ts new file mode 100644 index 00000000..9a77942b --- /dev/null +++ b/src/app/src/composables/useGitProvider.ts @@ -0,0 +1,46 @@ +import { createSharedComposable } from '@vueuse/core' +import type { GitOptions, GitProviderAPI, GitProviderType } from '../types' +import { createGitHubProvider, createGitLabProvider, createNullProvider } from '../utils/providers' + +function getProviderIcon(provider: GitProviderType | null): string { + switch (provider) { + case 'github': + return 'i-simple-icons:github' + case 'gitlab': + return 'i-simple-icons:gitlab' + default: + return 'i-simple-icons:git' + } +} + +function getProviderName(provider: GitProviderType | null): string { + switch (provider) { + case 'github': + return 'GitHub' + case 'gitlab': + return 'GitLab' + default: + return 'Local' + } +} + +function createProvider(provider: GitProviderType | null, options: GitOptions): GitProviderAPI { + switch (provider) { + case 'gitlab': + return createGitLabProvider(options) + case 'github': + return createGitHubProvider(options) + default: + return createNullProvider(options) + } +} + +export const useGitProvider = createSharedComposable((options: GitOptions, devMode: boolean = false) => { + const provider = devMode ? null : options.provider + + return { + name: getProviderName(provider), + icon: getProviderIcon(provider), + api: createProvider(provider, options), + } +}) diff --git a/src/app/src/composables/useGitProviderIcon.ts b/src/app/src/composables/useGitProviderIcon.ts deleted file mode 100644 index 72e26000..00000000 --- a/src/app/src/composables/useGitProviderIcon.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { computed } from 'vue' -import { useStudio } from './useStudio' -import type { GitProviderType } from '../types' - -export function useGitProviderIcon() { - const { host } = useStudio() - - const provider = computed(() => host.repository.provider) - - const icon = computed(() => { - switch (provider.value) { - case 'github': - return 'i-simple-icons:github' - case 'gitlab': - return 'i-simple-icons:gitlab' - default: - return 'i-simple-icons:git' - } - }) - - const providerName = computed(() => { - switch (provider.value) { - case 'github': - return 'GitHub' - case 'gitlab': - return 'GitLab' - default: - return 'Git' - } - }) - - return { - provider, - icon, - providerName, - } -} diff --git a/src/app/src/composables/useStudio.ts b/src/app/src/composables/useStudio.ts index 706693d3..675f7823 100644 --- a/src/app/src/composables/useStudio.ts +++ b/src/app/src/composables/useStudio.ts @@ -1,5 +1,5 @@ import { createSharedComposable } from '@vueuse/core' -import { useDevelopmentGit, useGit } from './useGit' +import { useGitProvider } from './useGitProvider' import { useUI } from './useUI' import { useContext } from './useContext' import { useDraftDocuments } from './useDraftDocuments' @@ -34,7 +34,7 @@ export const useStudio = createSharedComposable(() => { instanceUrl: host.repository.instanceUrl, } - const git = devMode.value ? useDevelopmentGit(gitOptions) : useGit(gitOptions) + const git = useGitProvider(gitOptions, devMode.value) const ui = useUI(host) const draftDocuments = useDraftDocuments(host, git) const documentTree = useTree(StudioFeature.Content, host, draftDocuments) diff --git a/src/app/src/pages/error.vue b/src/app/src/pages/error.vue index e5b2f5ce..7da37e85 100644 --- a/src/app/src/pages/error.vue +++ b/src/app/src/pages/error.vue @@ -2,20 +2,18 @@ import { computed } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useStudio } from '../composables/useStudio' -import { useGitProviderIcon } from '../composables/useGitProviderIcon' import { useI18n } from 'vue-i18n' const { t } = useI18n() const route = useRoute() const router = useRouter() const { git } = useStudio() -const { icon: gitProviderIcon, providerName } = useGitProviderIcon() const errorMessage = computed(() => { return (route.query.error as string) || t('studio.notifications.error.unknown') }) -const repositoryInfo = computed(() => git.getRepositoryInfo()) +const repositoryInfo = computed(() => git.api.getRepositoryInfo()) function retry() { router.push('/review') @@ -47,7 +45,7 @@ function retry() { { const queryCount = route.query.changeCount return queryCount ? Number.parseInt(queryCount as string, 10) : 0 }) -const repositoryInfo = computed(() => git.getRepositoryInfo()) +const repositoryInfo = computed(() => git.api.getRepositoryInfo()) const alertDescription = computed(() => { if (isWaitingForDeployment.value) { @@ -80,7 +78,7 @@ onMounted(() => { {