Skip to content

Commit 61211e0

Browse files
committed
Merge remote-tracking branch 'origin/main' into 12-12-refactor_sessions
2 parents a6e1855 + 335268e commit 61211e0

File tree

559 files changed

+2119
-859
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

559 files changed

+2119
-859
lines changed

apps/array/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
"autoprefixer": "^10.4.17",
5353
"electron": "^30.0.0",
5454
"husky": "^9.1.7",
55-
"immer": "^11.0.1",
5655
"jsdom": "^26.0.0",
5756
"lint-staged": "^15.5.2",
5857
"postcss": "^8.4.33",
@@ -126,6 +125,7 @@
126125
"electron-store": "^11.0.0",
127126
"file-icon": "^6.0.0",
128127
"idb-keyval": "^6.2.2",
128+
"immer": "^11.0.1",
129129
"is-glob": "^4.0.3",
130130
"micromatch": "^4.0.5",
131131
"node-addon-api": "^8.5.0",
@@ -143,6 +143,7 @@
143143
"remark-gfm": "^4.0.1",
144144
"sonner": "^2.0.7",
145145
"uuid": "^9.0.1",
146+
"vscode-icons-js": "^11.6.1",
146147
"zod": "^4.1.12",
147148
"zustand": "^4.5.0"
148149
}

apps/array/src/api/fetcher.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { buildApiFetcher } from "./fetcher";
3+
4+
describe("buildApiFetcher", () => {
5+
const mockFetch = vi.fn();
6+
const mockInput = {
7+
method: "get" as const,
8+
url: new URL("https://api.example.com/test"),
9+
path: "/test",
10+
};
11+
const ok = (data = {}) => ({
12+
ok: true,
13+
status: 200,
14+
json: () => Promise.resolve(data),
15+
});
16+
const err = (status: number) => ({
17+
ok: false,
18+
status,
19+
json: () => Promise.resolve({ error: status }),
20+
});
21+
22+
beforeEach(() => {
23+
vi.resetAllMocks();
24+
vi.stubGlobal("fetch", mockFetch);
25+
});
26+
27+
it("makes request with bearer token", async () => {
28+
mockFetch.mockResolvedValueOnce(ok());
29+
const fetcher = buildApiFetcher({ apiToken: "my-token" });
30+
31+
await fetcher.fetch(mockInput);
32+
33+
expect(mockFetch.mock.calls[0][1].headers.get("Authorization")).toBe(
34+
"Bearer my-token",
35+
);
36+
});
37+
38+
it("retries with new token on 401", async () => {
39+
const onTokenRefresh = vi.fn().mockResolvedValue("new-token");
40+
mockFetch.mockResolvedValueOnce(err(401)).mockResolvedValueOnce(ok());
41+
42+
const fetcher = buildApiFetcher({ apiToken: "old-token", onTokenRefresh });
43+
const response = await fetcher.fetch(mockInput);
44+
45+
expect(response.ok).toBe(true);
46+
expect(onTokenRefresh).toHaveBeenCalledTimes(1);
47+
expect(mockFetch.mock.calls[1][1].headers.get("Authorization")).toBe(
48+
"Bearer new-token",
49+
);
50+
});
51+
52+
it("uses refreshed token for subsequent requests", async () => {
53+
const onTokenRefresh = vi.fn().mockResolvedValue("refreshed-token");
54+
mockFetch
55+
.mockResolvedValueOnce(err(401))
56+
.mockResolvedValueOnce(ok())
57+
.mockResolvedValueOnce(ok());
58+
59+
const fetcher = buildApiFetcher({
60+
apiToken: "initial-token",
61+
onTokenRefresh,
62+
});
63+
await fetcher.fetch(mockInput);
64+
await fetcher.fetch(mockInput);
65+
66+
expect(mockFetch.mock.calls[2][1].headers.get("Authorization")).toBe(
67+
"Bearer refreshed-token",
68+
);
69+
});
70+
71+
it("does not refresh on non-401 errors", async () => {
72+
const onTokenRefresh = vi.fn();
73+
mockFetch.mockResolvedValueOnce(err(403));
74+
75+
const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh });
76+
77+
await expect(fetcher.fetch(mockInput)).rejects.toThrow("[403]");
78+
expect(onTokenRefresh).not.toHaveBeenCalled();
79+
});
80+
81+
it("throws on 401 without refresh callback", async () => {
82+
mockFetch.mockResolvedValueOnce(err(401));
83+
const fetcher = buildApiFetcher({ apiToken: "token" });
84+
85+
await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]");
86+
});
87+
88+
it("throws when refresh fails", async () => {
89+
const onTokenRefresh = vi.fn().mockRejectedValue(new Error("failed"));
90+
mockFetch.mockResolvedValueOnce(err(401));
91+
92+
const fetcher = buildApiFetcher({ apiToken: "token", onTokenRefresh });
93+
94+
await expect(fetcher.fetch(mockInput)).rejects.toThrow("[401]");
95+
});
96+
97+
it("handles network errors", async () => {
98+
mockFetch.mockRejectedValueOnce(new Error("Network failure"));
99+
const fetcher = buildApiFetcher({ apiToken: "token" });
100+
101+
await expect(fetcher.fetch(mockInput)).rejects.toThrow(
102+
"Network request failed",
103+
);
104+
});
105+
});

apps/array/src/api/fetcher.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export const buildApiFetcher: (config: {
44
apiToken: string;
55
onTokenRefresh?: () => Promise<string>;
66
}) => Parameters<typeof createApiClient>[0] = (config) => {
7+
let currentToken = config.apiToken;
8+
79
const makeRequest = async (
810
input: Parameters<Parameters<typeof createApiClient>[0]["fetch"]>[0],
911
token: string,
@@ -51,13 +53,14 @@ export const buildApiFetcher: (config: {
5153

5254
return {
5355
fetch: async (input) => {
54-
let response = await makeRequest(input, config.apiToken);
56+
let response = await makeRequest(input, currentToken);
5557

5658
// Handle 401 with automatic token refresh
5759
if (!response.ok && response.status === 401 && config.onTokenRefresh) {
5860
try {
5961
const newToken = await config.onTokenRefresh();
60-
response = await makeRequest(input, newToken);
62+
currentToken = newToken;
63+
response = await makeRequest(input, currentToken);
6164
} catch {
6265
// Token refresh failed - throw the original 401 error
6366
const errorResponse = await response.json();

apps/array/src/main/preload.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,33 @@ contextBridge.exposeInMainWorld("electronAPI", {
297297
fileStatus: string,
298298
): Promise<void> =>
299299
ipcRenderer.invoke("discard-file-changes", repoPath, filePath, fileStatus),
300+
getGitSyncStatus: (
301+
repoPath: string,
302+
): Promise<{
303+
ahead: number;
304+
behind: number;
305+
hasRemote: boolean;
306+
currentBranch: string | null;
307+
isFeatureBranch: boolean;
308+
}> => ipcRenderer.invoke("get-git-sync-status", repoPath),
309+
getLatestCommit: (
310+
repoPath: string,
311+
): Promise<{
312+
sha: string;
313+
shortSha: string;
314+
message: string;
315+
author: string;
316+
date: string;
317+
} | null> => ipcRenderer.invoke("get-latest-commit", repoPath),
318+
getGitRepoInfo: (
319+
repoPath: string,
320+
): Promise<{
321+
organization: string;
322+
repository: string;
323+
currentBranch: string | null;
324+
defaultBranch: string;
325+
compareUrl: string | null;
326+
} | null> => ipcRenderer.invoke("get-git-repo-info", repoPath),
300327
listDirectory: (
301328
dirPath: string,
302329
): Promise<

apps/array/src/main/services/git.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,158 @@ export interface DiffStats {
383383
linesRemoved: number;
384384
}
385385

386+
export interface GitSyncStatus {
387+
ahead: number;
388+
behind: number;
389+
hasRemote: boolean;
390+
currentBranch: string | null;
391+
isFeatureBranch: boolean;
392+
}
393+
394+
export interface GitCommitInfo {
395+
sha: string;
396+
shortSha: string;
397+
message: string;
398+
author: string;
399+
date: string;
400+
}
401+
402+
export interface GitRepoInfo {
403+
organization: string;
404+
repository: string;
405+
currentBranch: string | null;
406+
defaultBranch: string;
407+
compareUrl: string | null;
408+
}
409+
410+
const getLatestCommit = async (
411+
directoryPath: string,
412+
): Promise<GitCommitInfo | null> => {
413+
try {
414+
const { stdout } = await execAsync(
415+
'git log -1 --format="%H|%h|%s|%an|%aI"',
416+
{ cwd: directoryPath },
417+
);
418+
419+
const [sha, shortSha, message, author, date] = stdout.trim().split("|");
420+
if (!sha) return null;
421+
422+
return { sha, shortSha, message, author, date };
423+
} catch {
424+
return null;
425+
}
426+
};
427+
428+
const getGitRepoInfo = async (
429+
directoryPath: string,
430+
): Promise<GitRepoInfo | null> => {
431+
try {
432+
const remoteUrl = await getRemoteUrl(directoryPath);
433+
if (!remoteUrl) return null;
434+
435+
const parsed = parseGitHubUrl(remoteUrl);
436+
if (!parsed) return null;
437+
438+
const currentBranch = await getCurrentBranch(directoryPath);
439+
const defaultBranch = await getDefaultBranch(directoryPath);
440+
441+
let compareUrl: string | null = null;
442+
if (currentBranch && currentBranch !== defaultBranch) {
443+
compareUrl = `https://github.com/${parsed.organization}/${parsed.repository}/compare/${defaultBranch}...${currentBranch}?expand=1`;
444+
}
445+
446+
return {
447+
organization: parsed.organization,
448+
repository: parsed.repository,
449+
currentBranch: currentBranch ?? null,
450+
defaultBranch,
451+
compareUrl,
452+
};
453+
} catch {
454+
return null;
455+
}
456+
};
457+
458+
const getGitSyncStatus = async (
459+
directoryPath: string,
460+
): Promise<GitSyncStatus> => {
461+
try {
462+
const currentBranch = await getCurrentBranch(directoryPath);
463+
if (!currentBranch) {
464+
return {
465+
ahead: 0,
466+
behind: 0,
467+
hasRemote: false,
468+
currentBranch: null,
469+
isFeatureBranch: false,
470+
};
471+
}
472+
473+
const defaultBranch = await getDefaultBranch(directoryPath);
474+
const isFeatureBranch = currentBranch !== defaultBranch;
475+
476+
try {
477+
const { stdout: upstream } = await execAsync(
478+
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
479+
{ cwd: directoryPath },
480+
);
481+
482+
const upstreamBranch = upstream.trim();
483+
if (!upstreamBranch) {
484+
return {
485+
ahead: 0,
486+
behind: 0,
487+
hasRemote: false,
488+
currentBranch,
489+
isFeatureBranch,
490+
};
491+
}
492+
493+
// Use --quiet to suppress output, ignore errors (network may be unavailable)
494+
try {
495+
await execAsync("git fetch --quiet", {
496+
cwd: directoryPath,
497+
timeout: 10000,
498+
});
499+
} catch {
500+
// Fetch failed (likely offline), continue with stale data
501+
}
502+
503+
const { stdout: revList } = await execAsync(
504+
`git rev-list --left-right --count ${currentBranch}...${upstreamBranch}`,
505+
{ cwd: directoryPath },
506+
);
507+
508+
const [ahead, behind] = revList.trim().split("\t").map(Number);
509+
510+
return {
511+
ahead: ahead || 0,
512+
behind: behind || 0,
513+
hasRemote: true,
514+
currentBranch,
515+
isFeatureBranch,
516+
};
517+
} catch {
518+
return {
519+
ahead: 0,
520+
behind: 0,
521+
hasRemote: false,
522+
currentBranch,
523+
isFeatureBranch,
524+
};
525+
}
526+
} catch (error) {
527+
log.error("Error getting git sync status:", error);
528+
return {
529+
ahead: 0,
530+
behind: 0,
531+
hasRemote: false,
532+
currentBranch: null,
533+
isFeatureBranch: false,
534+
};
535+
}
536+
};
537+
386538
const discardFileChanges = async (
387539
directoryPath: string,
388540
filePath: string,
@@ -857,4 +1009,34 @@ export function registerGitIpc(
8571009
return discardFileChanges(directoryPath, filePath, fileStatus);
8581010
},
8591011
);
1012+
1013+
ipcMain.handle(
1014+
"get-git-sync-status",
1015+
async (
1016+
_event: IpcMainInvokeEvent,
1017+
directoryPath: string,
1018+
): Promise<GitSyncStatus> => {
1019+
return getGitSyncStatus(directoryPath);
1020+
},
1021+
);
1022+
1023+
ipcMain.handle(
1024+
"get-latest-commit",
1025+
async (
1026+
_event: IpcMainInvokeEvent,
1027+
directoryPath: string,
1028+
): Promise<GitCommitInfo | null> => {
1029+
return getLatestCommit(directoryPath);
1030+
},
1031+
);
1032+
1033+
ipcMain.handle(
1034+
"get-git-repo-info",
1035+
async (
1036+
_event: IpcMainInvokeEvent,
1037+
directoryPath: string,
1038+
): Promise<GitRepoInfo | null> => {
1039+
return getGitRepoInfo(directoryPath);
1040+
},
1041+
);
8601042
}
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading
Lines changed: 1 addition & 0 deletions
Loading

0 commit comments

Comments
 (0)