Skip to content

Commit 72bc387

Browse files
authored
Add GitHub issue picker and attachment menu to message editor (#1302)
1 parent 5134ec5 commit 72bc387

13 files changed

Lines changed: 697 additions & 83 deletions

File tree

apps/code/src/main/services/git/schemas.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,3 +395,22 @@ export const discardFileChangesOutput = z.object({
395395
});
396396

397397
export type DiscardFileChangesOutput = z.infer<typeof discardFileChangesOutput>;
398+
399+
export const githubIssueSchema = z.object({
400+
number: z.number(),
401+
title: z.string(),
402+
state: z.string(),
403+
labels: z.array(z.string()),
404+
url: z.string(),
405+
repo: z.string(),
406+
});
407+
408+
export type GitHubIssue = z.infer<typeof githubIssueSchema>;
409+
410+
export const searchGithubIssuesInput = z.object({
411+
directoryPath: z.string(),
412+
query: z.string().optional(),
413+
limit: z.number().default(25),
414+
});
415+
416+
export const searchGithubIssuesOutput = z.array(githubIssueSchema);

apps/code/src/main/services/git/service.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import type {
4646
GhStatusOutput,
4747
GitCommitInfo,
4848
GitFileStatus,
49+
GitHubIssue,
4950
GitRepoInfo,
5051
GitStateSnapshot,
5152
GitSyncStatus,
@@ -995,4 +996,106 @@ ${filesSummary || "(no file changes detected)"}`;
995996
body: bodyMatch?.[1]?.trim() ?? "",
996997
};
997998
}
999+
1000+
private async resolveCanonicalRepo(repo: string): Promise<string> {
1001+
const result = await execGh([
1002+
"repo",
1003+
"view",
1004+
repo,
1005+
"--json",
1006+
"name,owner",
1007+
"--jq",
1008+
'.owner.login + "/" + .name',
1009+
]);
1010+
if (result.exitCode !== 0) return repo;
1011+
return result.stdout.trim() || repo;
1012+
}
1013+
1014+
private parseGhIssues(stdout: string, repo: string): GitHubIssue[] {
1015+
const raw = JSON.parse(stdout) as Array<{
1016+
number: number;
1017+
title: string;
1018+
state: string;
1019+
labels: Array<{ name: string }>;
1020+
url: string;
1021+
}>;
1022+
const items = Array.isArray(raw) ? raw : [raw];
1023+
return items.map((issue) => ({
1024+
number: issue.number,
1025+
title: issue.title,
1026+
state: issue.state.toUpperCase(),
1027+
labels: issue.labels.map((l) => l.name),
1028+
url: issue.url,
1029+
repo,
1030+
}));
1031+
}
1032+
1033+
public async searchGithubIssues(
1034+
directoryPath: string,
1035+
query?: string,
1036+
limit = 5,
1037+
): Promise<GitHubIssue[]> {
1038+
const repoInfo = await this.getGitRepoInfo(directoryPath);
1039+
if (!repoInfo) return [];
1040+
1041+
const repo = await this.resolveCanonicalRepo(
1042+
`${repoInfo.organization}/${repoInfo.repository}`,
1043+
);
1044+
const trimmed = query?.trim().replace(/^#/, "");
1045+
const issueNumber = trimmed ? Number(trimmed) : Number.NaN;
1046+
1047+
if (!Number.isNaN(issueNumber) && Number.isInteger(issueNumber)) {
1048+
return this.fetchGhIssues(
1049+
["issue", "view", String(issueNumber), "--repo", repo],
1050+
repo,
1051+
);
1052+
}
1053+
1054+
if (trimmed) {
1055+
return this.fetchGhIssues(
1056+
[
1057+
"search",
1058+
"issues",
1059+
trimmed,
1060+
"--repo",
1061+
repo,
1062+
"--limit",
1063+
String(limit),
1064+
"--match",
1065+
"title",
1066+
],
1067+
repo,
1068+
);
1069+
}
1070+
1071+
return this.fetchGhIssues(
1072+
[
1073+
"issue",
1074+
"list",
1075+
"--repo",
1076+
repo,
1077+
"--limit",
1078+
String(limit),
1079+
"--state",
1080+
"all",
1081+
],
1082+
repo,
1083+
);
1084+
}
1085+
1086+
private async fetchGhIssues(
1087+
args: string[],
1088+
repo: string,
1089+
): Promise<GitHubIssue[]> {
1090+
const jsonFields = "number,title,state,labels,url";
1091+
const result = await execGh([...args, "--json", jsonFields]);
1092+
if (result.exitCode !== 0) return [];
1093+
1094+
try {
1095+
return this.parseGhIssues(result.stdout, repo);
1096+
} catch {
1097+
log.warn("Failed to parse GitHub issues response", { repo, args });
1098+
return [];
1099+
}
1100+
}
9981101
}

apps/code/src/main/trpc/routers/git.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ import {
5353
pullOutput,
5454
pushInput,
5555
pushOutput,
56+
searchGithubIssuesInput,
57+
searchGithubIssuesOutput,
5658
syncInput,
5759
syncOutput,
5860
validateRepoInput,
@@ -288,4 +290,15 @@ export const gitRouter = router({
288290
input.credentials,
289291
),
290292
),
293+
294+
searchGithubIssues: publicProcedure
295+
.input(searchGithubIssuesInput)
296+
.output(searchGithubIssuesOutput)
297+
.query(({ input }) =>
298+
getService().searchGithubIssues(
299+
input.directoryPath,
300+
input.query,
301+
input.limit,
302+
),
303+
),
291304
});
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
.attachment-menu {
2+
padding: var(--space-1);
3+
min-width: 140px;
4+
}
5+
6+
.attachment-menu-item {
7+
all: unset;
8+
box-sizing: border-box;
9+
display: flex;
10+
align-items: center;
11+
gap: var(--space-2);
12+
width: 100%;
13+
height: var(--space-5);
14+
padding: 0 var(--space-2);
15+
font-size: 12px;
16+
line-height: var(--line-height-1);
17+
letter-spacing: var(--letter-spacing-1);
18+
color: var(--gray-12);
19+
border-radius: var(--radius-1);
20+
cursor: pointer;
21+
user-select: none;
22+
white-space: nowrap;
23+
}
24+
25+
.attachment-menu-item:hover {
26+
background: var(--accent-a4);
27+
}
28+
29+
.attachment-menu-item:disabled {
30+
opacity: 0.5;
31+
cursor: default;
32+
pointer-events: none;
33+
}
34+
35+
.attachment-menu-item-icon {
36+
flex-shrink: 0;
37+
display: flex;
38+
align-items: center;
39+
color: var(--gray-11);
40+
}
41+
42+
.issue-picker [cmdk-root] {
43+
display: flex;
44+
flex-direction: column;
45+
max-height: 340px;
46+
width: 300px;
47+
overflow: hidden;
48+
}
49+
50+
.issue-picker .combobox-input-wrapper {
51+
display: flex;
52+
align-items: center;
53+
gap: var(--space-2);
54+
padding: var(--space-1) var(--space-2);
55+
flex-shrink: 0;
56+
background: var(--gray-2);
57+
border-bottom: 1px solid var(--gray-a6);
58+
}
59+
60+
.issue-picker .combobox-input-icon {
61+
color: var(--gray-9);
62+
flex-shrink: 0;
63+
}
64+
65+
.issue-picker [cmdk-input] {
66+
font-family: var(--default-font-family);
67+
padding: 0;
68+
width: 100%;
69+
background: transparent;
70+
border: none;
71+
outline: none;
72+
color: var(--gray-12);
73+
caret-color: var(--accent-9);
74+
font-size: 12px;
75+
line-height: var(--line-height-1);
76+
flex: 1;
77+
}
78+
79+
.issue-picker [cmdk-input]::placeholder {
80+
color: var(--gray-9);
81+
}
82+
83+
.issue-picker [cmdk-list] {
84+
flex: 1;
85+
min-height: 230px;
86+
overflow-y: auto;
87+
overflow-x: hidden;
88+
overscroll-behavior: contain;
89+
scrollbar-width: thin;
90+
scrollbar-color: var(--gray-a5) transparent;
91+
}
92+
93+
.issue-picker [cmdk-group] {
94+
padding: var(--space-1);
95+
}
96+
97+
.issue-picker [cmdk-item] {
98+
display: flex;
99+
padding: var(--space-1) var(--space-2);
100+
cursor: pointer;
101+
user-select: none;
102+
color: var(--gray-12);
103+
outline: none;
104+
border-radius: var(--radius-1);
105+
font-size: 12px;
106+
line-height: var(--line-height-1);
107+
letter-spacing: var(--letter-spacing-1);
108+
min-width: 0;
109+
overflow: hidden;
110+
}
111+
112+
.issue-picker [cmdk-item][data-selected="true"] {
113+
background: var(--accent-a4);
114+
color: var(--accent-12);
115+
}
116+
117+
.issue-picker [cmdk-item]:active {
118+
background: var(--accent-4);
119+
}
120+
121+
.issue-picker [cmdk-empty] {
122+
display: flex;
123+
align-items: center;
124+
justify-content: center;
125+
height: 64px;
126+
font-size: 12px;
127+
color: var(--gray-9);
128+
}
129+
130+
.issue-picker-text {
131+
display: flex;
132+
flex-direction: column;
133+
gap: 2px;
134+
min-width: 0;
135+
overflow: hidden;
136+
}
137+
138+
.issue-picker-title {
139+
display: flex;
140+
align-items: center;
141+
gap: var(--space-1);
142+
overflow: hidden;
143+
text-overflow: ellipsis;
144+
white-space: nowrap;
145+
font-weight: 500;
146+
}
147+
148+
.issue-picker-dot {
149+
width: 8px;
150+
height: 8px;
151+
border-radius: 50%;
152+
flex-shrink: 0;
153+
}
154+
155+
.issue-picker-meta {
156+
overflow: hidden;
157+
text-overflow: ellipsis;
158+
white-space: nowrap;
159+
color: var(--gray-a10);
160+
font-size: 11px;
161+
}

0 commit comments

Comments
 (0)