Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 2975836

Browse files
authored
feat: add git ui with steps (#843)
1 parent d1beeff commit 2975836

32 files changed

Lines changed: 3439 additions & 568 deletions

File tree

‎apps/twig/src/main/di/container.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { FocusSyncService } from "../services/focus/sync-service.js";
1313
import { FoldersService } from "../services/folders/service.js";
1414
import { FsService } from "../services/fs/service.js";
1515
import { GitService } from "../services/git/service.js";
16+
import { LlmGatewayService } from "../services/llm-gateway/service.js";
1617
import { NotificationService } from "../services/notification/service.js";
1718
import { OAuthService } from "../services/oauth/service.js";
1819
import { ProcessTrackingService } from "../services/process-tracking/service.js";
@@ -36,6 +37,7 @@ container.bind(MAIN_TOKENS.ContextMenuService).to(ContextMenuService);
3637
container.bind(MAIN_TOKENS.DeepLinkService).to(DeepLinkService);
3738

3839
container.bind(MAIN_TOKENS.ExternalAppsService).to(ExternalAppsService);
40+
container.bind(MAIN_TOKENS.LlmGatewayService).to(LlmGatewayService);
3941
container.bind(MAIN_TOKENS.FileWatcherService).to(FileWatcherService);
4042
container.bind(MAIN_TOKENS.FocusService).to(FocusService);
4143
container.bind(MAIN_TOKENS.FocusSyncService).to(FocusSyncService);

‎apps/twig/src/main/di/tokens.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const MAIN_TOKENS = Object.freeze({
1212
ContextMenuService: Symbol.for("Main.ContextMenuService"),
1313

1414
ExternalAppsService: Symbol.for("Main.ExternalAppsService"),
15+
LlmGatewayService: Symbol.for("Main.LlmGatewayService"),
1516
FileWatcherService: Symbol.for("Main.FileWatcherService"),
1617
FocusService: Symbol.for("Main.FocusService"),
1718
FocusSyncService: Symbol.for("Main.FocusSyncService"),

‎apps/twig/src/main/services/file-watcher/service.ts‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ interface PendingChanges {
2929

3030
interface RepoWatcher {
3131
filesId: string;
32-
gitId: string | null;
32+
gitIds: string[];
3333
pending: PendingChanges;
3434
}
3535

@@ -81,19 +81,23 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
8181
};
8282

8383
const filesId = `file-watcher:files:${repoPath}`;
84-
const gitId = `file-watcher:git:${repoPath}`;
8584

8685
const filesSub = await this.watchFiles(repoPath, pending);
8786
this.watcherRegistry.register(filesId, filesSub);
8887

89-
const gitSub = await this.watchGit(repoPath);
90-
if (gitSub) {
91-
this.watcherRegistry.register(gitId, gitSub);
88+
const gitIds: string[] = [];
89+
const gitSubs = await this.watchGit(repoPath);
90+
if (gitSubs) {
91+
for (let i = 0; i < gitSubs.length; i++) {
92+
const gitId = `file-watcher:git:${repoPath}:${i}`;
93+
this.watcherRegistry.register(gitId, gitSubs[i]);
94+
gitIds.push(gitId);
95+
}
9296
}
9397

9498
this.watchers.set(repoPath, {
9599
filesId,
96-
gitId: gitSub ? gitId : null,
100+
gitIds,
97101
pending,
98102
});
99103
}
@@ -105,8 +109,8 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
105109
if (w.pending.timer) clearTimeout(w.pending.timer);
106110
await this.saveSnapshot(repoPath);
107111
await this.watcherRegistry.unregister(w.filesId);
108-
if (w.gitId) {
109-
await this.watcherRegistry.unregister(w.gitId);
112+
for (const gitId of w.gitIds) {
113+
await this.watcherRegistry.unregister(gitId);
110114
}
111115
this.watchers.delete(repoPath);
112116
}
@@ -231,10 +235,12 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
231235

232236
private async watchGit(
233237
repoPath: string,
234-
): Promise<watcher.AsyncSubscription | null> {
238+
): Promise<watcher.AsyncSubscription[] | null> {
235239
try {
236240
const gitDir = await this.resolveGitDir(repoPath);
237-
return watcher.subscribe(gitDir, (err, events) => {
241+
const subscriptions: watcher.AsyncSubscription[] = [];
242+
243+
const handleEvents = (err: Error | null, events: watcher.Event[]) => {
238244
if (this.watcherRegistry.isShutdown) return;
239245
if (err) {
240246
log.error("Git watcher error:", err);
@@ -249,13 +255,32 @@ export class FileWatcherService extends TypedEventEmitter<FileWatcherEvents> {
249255
if (isRelevant) {
250256
this.emit(FileWatcherEvent.GitStateChanged, { repoPath });
251257
}
252-
});
258+
};
259+
260+
subscriptions.push(await watcher.subscribe(gitDir, handleEvents));
261+
262+
const commonDir = await this.resolveCommonDir(gitDir);
263+
if (commonDir && commonDir !== gitDir) {
264+
subscriptions.push(await watcher.subscribe(commonDir, handleEvents));
265+
}
266+
267+
return subscriptions;
253268
} catch (error) {
254269
log.warn("Failed to set up git watcher:", error);
255270
return null;
256271
}
257272
}
258273

274+
private async resolveCommonDir(gitDir: string): Promise<string | null> {
275+
try {
276+
const commonDirFile = path.join(gitDir, "commondir");
277+
const content = await fs.readFile(commonDirFile, "utf-8");
278+
return path.resolve(gitDir, content.trim());
279+
} catch {
280+
return null;
281+
}
282+
}
283+
259284
private async resolveGitDir(repoPath: string): Promise<string> {
260285
const gitPath = path.join(repoPath, ".git");
261286
const stat = await fs.stat(gitPath);

‎apps/twig/src/main/services/git/schemas.ts‎

Lines changed: 154 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -168,13 +168,7 @@ export const pushInput = z.object({
168168
setUpstream: z.boolean().default(false),
169169
});
170170

171-
export const pushOutput = z.object({
172-
success: z.boolean(),
173-
message: z.string(),
174-
});
175-
176171
export type PushInput = z.infer<typeof pushInput>;
177-
export type PushOutput = z.infer<typeof pushOutput>;
178172

179173
// Pull operation
180174
export const pullInput = z.object({
@@ -183,44 +177,84 @@ export const pullInput = z.object({
183177
branch: z.string().optional(),
184178
});
185179

186-
export const pullOutput = z.object({
187-
success: z.boolean(),
180+
export type PullInput = z.infer<typeof pullInput>;
181+
182+
// Commit operation
183+
export const commitInput = z.object({
184+
directoryPath: z.string(),
188185
message: z.string(),
189-
updatedFiles: z.number().optional(),
186+
paths: z.array(z.string()).optional(),
187+
allowEmpty: z.boolean().optional(),
190188
});
191189

192-
export type PullInput = z.infer<typeof pullInput>;
193-
export type PullOutput = z.infer<typeof pullOutput>;
190+
export type CommitInput = z.infer<typeof commitInput>;
194191

195-
// Publish (push with upstream) operation
196-
export const publishInput = z.object({
192+
// GitHub CLI status
193+
export const ghStatusOutput = z.object({
194+
installed: z.boolean(),
195+
version: z.string().nullable(),
196+
authenticated: z.boolean(),
197+
username: z.string().nullable(),
198+
error: z.string().nullable(),
199+
});
200+
201+
export type GhStatusOutput = z.infer<typeof ghStatusOutput>;
202+
203+
// Pull request status
204+
export const prStatusInput = directoryPathInput;
205+
export const prStatusOutput = z.object({
206+
hasRemote: z.boolean(),
207+
isGitHubRepo: z.boolean(),
208+
currentBranch: z.string().nullable(),
209+
defaultBranch: z.string().nullable(),
210+
prExists: z.boolean(),
211+
prUrl: z.string().nullable(),
212+
prState: z.string().nullable(),
213+
baseBranch: z.string().nullable(),
214+
headBranch: z.string().nullable(),
215+
isDraft: z.boolean().nullable(),
216+
error: z.string().nullable(),
217+
});
218+
219+
export type PrStatusInput = z.infer<typeof prStatusInput>;
220+
export type PrStatusOutput = z.infer<typeof prStatusOutput>;
221+
222+
// Create PR operation
223+
export const createPrInput = z.object({
197224
directoryPath: z.string(),
198-
remote: z.string().default("origin"),
225+
title: z.string().optional(),
226+
body: z.string().optional(),
227+
draft: z.boolean().optional(),
199228
});
200229

201-
export const publishOutput = z.object({
230+
export type CreatePrInput = z.infer<typeof createPrInput>;
231+
232+
// Open PR operation
233+
export const openPrInput = directoryPathInput;
234+
export const openPrOutput = z.object({
202235
success: z.boolean(),
203236
message: z.string(),
204-
branch: z.string(),
237+
prUrl: z.string().nullable(),
238+
});
239+
240+
export type OpenPrInput = z.infer<typeof openPrInput>;
241+
export type OpenPrOutput = z.infer<typeof openPrOutput>;
242+
243+
// Publish (push with upstream) operation
244+
export const publishInput = z.object({
245+
directoryPath: z.string(),
246+
remote: z.string().default("origin"),
205247
});
206248

207249
export type PublishInput = z.infer<typeof publishInput>;
208-
export type PublishOutput = z.infer<typeof publishOutput>;
209250

210251
// Sync (pull then push) operation
211252
export const syncInput = z.object({
212253
directoryPath: z.string(),
213254
remote: z.string().default("origin"),
214255
});
215256

216-
export const syncOutput = z.object({
217-
success: z.boolean(),
218-
pullMessage: z.string(),
219-
pushMessage: z.string(),
220-
});
221-
222257
export type SyncInput = z.infer<typeof syncInput>;
223-
export type SyncOutput = z.infer<typeof syncOutput>;
224258

225259
// PR Template lookup
226260
export const getPrTemplateInput = directoryPathInput;
@@ -247,3 +281,99 @@ export const getCommitConventionsOutput = z.object({
247281
export type GetCommitConventionsOutput = z.infer<
248282
typeof getCommitConventionsOutput
249283
>;
284+
285+
export const generateCommitMessageInput = z.object({
286+
directoryPath: z.string(),
287+
credentials: z.object({
288+
apiKey: z.string(),
289+
apiHost: z.string(),
290+
}),
291+
});
292+
293+
export const generateCommitMessageOutput = z.object({
294+
message: z.string(),
295+
});
296+
297+
export const generatePrTitleAndBodyInput = z.object({
298+
directoryPath: z.string(),
299+
credentials: z.object({
300+
apiKey: z.string(),
301+
apiHost: z.string(),
302+
}),
303+
});
304+
305+
export const generatePrTitleAndBodyOutput = z.object({
306+
title: z.string(),
307+
body: z.string(),
308+
});
309+
310+
export const gitStateSnapshotSchema = z.object({
311+
changedFiles: z.array(changedFileSchema).optional(),
312+
diffStats: diffStatsSchema.optional(),
313+
syncStatus: gitSyncStatusSchema.optional(),
314+
latestCommit: gitCommitInfoSchema.nullable().optional(),
315+
prStatus: prStatusOutput.optional(),
316+
});
317+
318+
export type GitStateSnapshot = z.infer<typeof gitStateSnapshotSchema>;
319+
320+
export const commitOutput = z.object({
321+
success: z.boolean(),
322+
message: z.string(),
323+
commitSha: z.string().nullable(),
324+
branch: z.string().nullable(),
325+
state: gitStateSnapshotSchema.optional(),
326+
});
327+
328+
export type CommitOutput = z.infer<typeof commitOutput>;
329+
330+
export const pushOutput = z.object({
331+
success: z.boolean(),
332+
message: z.string(),
333+
state: gitStateSnapshotSchema.optional(),
334+
});
335+
336+
export type PushOutput = z.infer<typeof pushOutput>;
337+
338+
export const pullOutput = z.object({
339+
success: z.boolean(),
340+
message: z.string(),
341+
updatedFiles: z.number().optional(),
342+
state: gitStateSnapshotSchema.optional(),
343+
});
344+
345+
export type PullOutput = z.infer<typeof pullOutput>;
346+
347+
export const publishOutput = z.object({
348+
success: z.boolean(),
349+
message: z.string(),
350+
branch: z.string(),
351+
state: gitStateSnapshotSchema.optional(),
352+
});
353+
354+
export type PublishOutput = z.infer<typeof publishOutput>;
355+
356+
export const syncOutput = z.object({
357+
success: z.boolean(),
358+
pullMessage: z.string(),
359+
pushMessage: z.string(),
360+
state: gitStateSnapshotSchema.optional(),
361+
});
362+
363+
export type SyncOutput = z.infer<typeof syncOutput>;
364+
365+
export const createPrOutput = z.object({
366+
success: z.boolean(),
367+
message: z.string(),
368+
prUrl: z.string().nullable(),
369+
state: gitStateSnapshotSchema.optional(),
370+
});
371+
372+
export type CreatePrOutput = z.infer<typeof createPrOutput>;
373+
374+
export const discardFileChangesOutput = z.object({
375+
success: z.boolean(),
376+
state: gitStateSnapshotSchema.optional(),
377+
});
378+
379+
export type DiscardFileChangesOutput = z.infer<typeof discardFileChangesOutput>;

0 commit comments

Comments
 (0)