Skip to content

Commit bf6bddf

Browse files
authored
Implement support for GitLab (#9)
1 parent 1e0c7d6 commit bf6bddf

9 files changed

Lines changed: 180 additions & 23 deletions

File tree

Readme.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ you to [devpod.sh/open](https://devpod.sh/open) when you click the button.
1313

1414
## Features
1515

16-
- Adds a DevPod button on Github
16+
- Adds a DevPod button on **Github** and **GitLab**
1717
- On the main repository page
1818
- When exploring branches
1919
- On PRs
@@ -37,7 +37,6 @@ Then in the extension settings, enable developer mode and click
3737
- [ ] Action button support
3838
- [ ] Support more platforms
3939
- [ ] Gitea & Forgejo
40-
- [ ] GitLab
4140
- [ ] sourcehut
4241
- [ ] Bitbucket
4342
- [ ] Add configurable settings

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "clone-with-devpod-extension",
33
"private": true,
4-
"version": "0.1.2",
4+
"version": "0.2.0",
55
"type": "module",
66
"license": "MIT",
77
"scripts": {

src/lib/integrations/core.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
export type IntegrationPlatform = "github";
1+
export type IntegrationPlatform = "Github" | "GitLab";
22

33
export type Integration = {
44
platform: IntegrationPlatform;
55
supports(url: string | URL): boolean;
66
getButtonTarget(document: Document): HTMLElement;
77
getRepo(params: { url: string | URL; document: Document }): string;
88
getBranch(params: { url: string | URL; document: Document }): string;
9+
/** If given, these additional classes will be applied to the Clone with DevPod button. */
10+
buttonClassOverride?: (params: {
11+
url: string | URL;
12+
document: Document;
13+
}) => string | undefined;
914
};

src/lib/integrations/github.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import _ from "lodash";
1010
import { findDOMNodeByContent } from "@lib/utils/dom/findDOMNodeByContent";
1111

1212
type EGithubParseErrorData = EIntegrationParseErrorData & {
13-
integration: "github";
13+
integration: "Github";
1414
};
1515
export class EGithubParseError extends EIntegrationParseError {
1616
constructor({
@@ -26,10 +26,10 @@ function isPR(url: string | URL) {
2626
return /[/](?<repo>[^/]+[/][^/]+)([/]?pull[/](\d+))?/.test(url.toString());
2727
}
2828

29-
export const github: Integration = {
30-
platform: "github",
29+
export const Github: Integration = {
30+
platform: "Github",
3131
supports(url: string | URL) {
32-
return /^https?:[/][/]github.com[/][^/]+[/][^/]+/.test(url.toString());
32+
return /^https?:[/][/]Github.com[/][^/]+[/][^/]+/.test(url.toString());
3333
},
3434
getButtonTarget(document: Document) {
3535
const node =
@@ -39,7 +39,7 @@ export const github: Integration = {
3939
throw new EIntegrationTargetError({
4040
message: "Unable to find button target",
4141
data: {
42-
integration: "github",
42+
integration: "Github",
4343
url: window.location.href,
4444
},
4545
});
@@ -55,7 +55,7 @@ export const github: Integration = {
5555
throw new EGithubParseError({
5656
data: {
5757
url: url.toString(),
58-
integration: "github",
58+
integration: "Github",
5959
cause: "no match",
6060
process: "repo",
6161
},
@@ -66,7 +66,7 @@ export const github: Integration = {
6666
throw new EGithubParseError({
6767
data: {
6868
url: url.toString(),
69-
integration: "github",
69+
integration: "Github",
7070
cause: "empty match",
7171
process: "repo",
7272
},
@@ -81,7 +81,7 @@ export const github: Integration = {
8181
throw new EGithubParseError({
8282
data: {
8383
url: url.toString(),
84-
integration: "github",
84+
integration: "Github",
8585
cause: "no match",
8686
process: "branch",
8787
},
@@ -92,7 +92,7 @@ export const github: Integration = {
9292
throw new EGithubParseError({
9393
data: {
9494
url: url.toString(),
95-
integration: "github",
95+
integration: "Github",
9696
cause: "empty match",
9797
process: "branch",
9898
},
@@ -101,14 +101,14 @@ export const github: Integration = {
101101
return branch;
102102
} else {
103103
const results =
104-
/[/](?<repo>[^/]+[/][^/]+)([/]?tree[/](?<branch>[^?]+))?/.exec(
104+
/[/](?<repo>[^/]+[/][^/]+)([/]?tree[/](?<branch>[^?]+))/.exec(
105105
url.toString(),
106106
)?.groups;
107107
if (!results) {
108108
throw new EGithubParseError({
109109
data: {
110110
url: window.location.href,
111-
integration: "github",
111+
integration: "Github",
112112
cause: "no match",
113113
process: "branch",
114114
},
@@ -119,7 +119,7 @@ export const github: Integration = {
119119
throw new EGithubParseError({
120120
data: {
121121
url: url.toString(),
122-
integration: "github",
122+
integration: "Github",
123123
cause: "empty match",
124124
process: "branch",
125125
},

src/lib/integrations/gitlab.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { WithPartial } from "@lib/utils/typeUtils/WithPartial";
2+
import { Integration } from "./core";
3+
import {
4+
EIntegrationParseError,
5+
EIntegrationParseErrorData,
6+
EIntegrationTargetError,
7+
} from "./error";
8+
import { EErrorOptions } from "@lib/utils/error";
9+
import _ from "lodash";
10+
11+
type EGitLabParseErrorData = EIntegrationParseErrorData & {
12+
integration: "GitLab";
13+
};
14+
export class EGitLabParseError extends EIntegrationParseError {
15+
constructor({
16+
message = "Unable to extract repository or branch from URL",
17+
data,
18+
}: WithPartial<EErrorOptions<EGitLabParseErrorData>, "message">) {
19+
super({ message, data });
20+
this.name = "EGitLabParseErrorData";
21+
}
22+
}
23+
24+
function isPR(url: string | URL) {
25+
return /[/][^/]+[/][^/]+[/]-[/]merge_requests[/].+/.test(url.toString());
26+
}
27+
28+
export const GitLab: Integration = {
29+
platform: "GitLab",
30+
supports(url: string | URL) {
31+
return /^https?:[/][/]gitlab.com[/][^/]+[/][^/]+/.test(url.toString());
32+
},
33+
getButtonTarget(document: Document) {
34+
const node =
35+
document.querySelector<HTMLElement>(".project-code-holder")
36+
?.parentElement ??
37+
document.querySelector<HTMLElement>(".tree-controls > div") ??
38+
document.querySelector<HTMLElement>(".detail-page-header-actions");
39+
if (!node) {
40+
throw new EIntegrationTargetError({
41+
message: "Unable to find button target",
42+
data: {
43+
integration: "GitLab",
44+
url: window.location.href,
45+
},
46+
});
47+
}
48+
return node;
49+
},
50+
getRepo({ url }) {
51+
const results =
52+
/[/](?<repo>[^/]+[/][^/]+)([/]?tree[/](?<branch>[^?]+))?/.exec(
53+
url.toString(),
54+
)?.groups;
55+
if (!results) {
56+
throw new EGitLabParseError({
57+
data: {
58+
url: url.toString(),
59+
integration: "GitLab",
60+
cause: "no match",
61+
process: "repo",
62+
},
63+
});
64+
}
65+
const { repo } = results;
66+
if (_.isEmpty(repo)) {
67+
throw new EGitLabParseError({
68+
data: {
69+
url: url.toString(),
70+
integration: "GitLab",
71+
cause: "empty match",
72+
process: "repo",
73+
},
74+
});
75+
}
76+
return repo;
77+
},
78+
getBranch({ url, document }) {
79+
if (isPR(url)) {
80+
const branchContainer = document.querySelector(".ref-container");
81+
if (!branchContainer) {
82+
throw new EGitLabParseError({
83+
data: {
84+
url: url.toString(),
85+
integration: "GitLab",
86+
cause: "no match",
87+
process: "branch",
88+
},
89+
});
90+
}
91+
const branch = branchContainer.textContent;
92+
if (!branch || _.isEmpty(branch)) {
93+
throw new EGitLabParseError({
94+
data: {
95+
url: url.toString(),
96+
integration: "GitLab",
97+
cause: "empty match",
98+
process: "branch",
99+
},
100+
});
101+
}
102+
return branch;
103+
} else {
104+
const results = /[/][^/]+[/][^/]+[/]-[/]tree[/](?<branch>[^?]+)/.exec(
105+
url.toString(),
106+
)?.groups;
107+
if (!results) {
108+
throw new EGitLabParseError({
109+
data: {
110+
url: window.location.href,
111+
integration: "GitLab",
112+
cause: "no match",
113+
process: "branch",
114+
},
115+
});
116+
}
117+
const { branch } = results;
118+
if (_.isEmpty(branch)) {
119+
throw new EGitLabParseError({
120+
data: {
121+
url: url.toString(),
122+
integration: "GitLab",
123+
cause: "empty match",
124+
process: "branch",
125+
},
126+
});
127+
}
128+
return branch;
129+
}
130+
},
131+
buttonClassOverride({ url }) {
132+
if (isPR(url)) {
133+
return "ml-2";
134+
}
135+
},
136+
};

src/lib/integrations/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { github } from "./github";
1+
import { Github } from "./github";
2+
import { GitLab } from "./gitlab";
23

3-
const INTEGRATIONS = [github] as const;
4+
const INTEGRATIONS = [Github, GitLab] as const;
45

56
export function getSupportedIntegration(url: string | URL) {
67
return INTEGRATIONS.find((integration) => integration.supports(url));

src/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"permissions": ["webNavigation", "storage"],
2424
"content_scripts": [
2525
{
26-
"matches": ["http://github.com/*", "https://github.com/*"],
26+
"matches": ["https://github.com/*", "https://gitlab.com/*"],
2727
"js": ["src/pages/Content.tsx"]
2828
}
2929
]

src/pages/Content.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,19 @@ function init(attempts: number = 0) {
3131
buttonContainer = rootContainerTarget;
3232
const root = createRoot(rootContainer);
3333
const { shadow: portalContainer } = attachShadow(document.body);
34-
root.render(<CloneButton portal={portalContainer} />);
34+
root.render(
35+
<CloneButton
36+
className={integration.buttonClassOverride?.({
37+
url: window.location.href,
38+
document,
39+
})}
40+
portal={portalContainer}
41+
/>,
42+
);
3543
} catch (error) {
3644
if (error instanceof ENoIntegrationError) {
3745
// Ignore, expected error when the site is not supported.
46+
console.debug("No integration found for this site");
3847
} else {
3948
console.info("Initialization failed", {
4049
attempts,

src/pages/content/CloneButton.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,10 @@ function getDevPodUrl(url: string) {
3838
}
3939
}
4040

41-
function CloneButtonInner({ portal }: PortalProps) {
41+
function CloneButtonInner({
42+
portal,
43+
className,
44+
}: PortalProps & { className?: string }) {
4245
const { isHovering, bindTarget, bindTooltip } =
4346
useTooltip<HTMLAnchorElement>();
4447

@@ -52,6 +55,7 @@ function CloneButtonInner({ portal }: PortalProps) {
5255
href={link}
5356
color="primary"
5457
{...bindTarget}
58+
className={className}
5559
>
5660
<DevPodLogoIcon
5761
aria-label=""
@@ -75,11 +79,14 @@ function CloneButtonInner({ portal }: PortalProps) {
7579
);
7680
}
7781

78-
export function CloneButton({ portal }: PortalProps) {
82+
export function CloneButton({
83+
portal,
84+
className,
85+
}: PortalProps & { className?: string }) {
7986
return (
8087
<StrictMode>
8188
<ErrorBoundaryProvider>
82-
<CloneButtonInner portal={portal} />
89+
<CloneButtonInner className={className} portal={portal} />
8390
</ErrorBoundaryProvider>
8491
</StrictMode>
8592
);

0 commit comments

Comments
 (0)