Skip to content

Commit 0f6b754

Browse files
authored
Merge branch 'main' into chore/bump-and-test
2 parents 6bff955 + 0481eab commit 0f6b754

File tree

13 files changed

+409
-120
lines changed

13 files changed

+409
-120
lines changed

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

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,37 @@ async function getFileIcon() {
2424
let cachedApps: DetectedApplication[] | null = null;
2525
let detectionPromise: Promise<DetectedApplication[]> | null = null;
2626

27-
const APP_PATHS: Record<string, string> = {
28-
vscode: "/Applications/Visual Studio Code.app",
29-
cursor: "/Applications/Cursor.app",
30-
sublime: "/Applications/Sublime Text.app",
31-
webstorm: "/Applications/WebStorm.app",
32-
intellij: "/Applications/IntelliJ IDEA.app",
33-
zed: "/Applications/Zed.app",
34-
pycharm: "/Applications/PyCharm.app",
35-
iterm: "/Applications/iTerm.app",
36-
warp: "/Applications/Warp.app",
37-
terminal: "/System/Applications/Utilities/Terminal.app",
38-
alacritty: "/Applications/Alacritty.app",
39-
kitty: "/Applications/kitty.app",
40-
ghostty: "/Applications/Ghostty.app",
41-
finder: "/System/Library/CoreServices/Finder.app",
27+
interface AppDefinition {
28+
path: string;
29+
type: ExternalAppType;
30+
}
31+
32+
const APP_DEFINITIONS: Record<string, AppDefinition> = {
33+
// Editors
34+
vscode: { path: "/Applications/Visual Studio Code.app", type: "editor" },
35+
cursor: { path: "/Applications/Cursor.app", type: "editor" },
36+
sublime: { path: "/Applications/Sublime Text.app", type: "editor" },
37+
webstorm: { path: "/Applications/WebStorm.app", type: "editor" },
38+
intellij: { path: "/Applications/IntelliJ IDEA.app", type: "editor" },
39+
zed: { path: "/Applications/Zed.app", type: "editor" },
40+
pycharm: { path: "/Applications/PyCharm.app", type: "editor" },
41+
42+
// Terminals
43+
iterm: { path: "/Applications/iTerm.app", type: "terminal" },
44+
warp: { path: "/Applications/Warp.app", type: "terminal" },
45+
terminal: {
46+
path: "/System/Applications/Utilities/Terminal.app",
47+
type: "terminal",
48+
},
49+
alacritty: { path: "/Applications/Alacritty.app", type: "terminal" },
50+
kitty: { path: "/Applications/kitty.app", type: "terminal" },
51+
ghostty: { path: "/Applications/Ghostty.app", type: "terminal" },
52+
53+
// File managers
54+
finder: {
55+
path: "/System/Library/CoreServices/Finder.app",
56+
type: "file-manager",
57+
},
4258
};
4359

4460
const DISPLAY_NAMES: Record<string, string> = {
@@ -129,8 +145,12 @@ async function checkApplication(
129145
async function detectExternalApps(): Promise<DetectedApplication[]> {
130146
const apps: DetectedApplication[] = [];
131147

132-
for (const [id, appPath] of Object.entries(APP_PATHS)) {
133-
const detected = await checkApplication(id, appPath, "editor");
148+
for (const [id, definition] of Object.entries(APP_DEFINITIONS)) {
149+
const detected = await checkApplication(
150+
id,
151+
definition.path,
152+
definition.type,
153+
);
134154
if (detected) {
135155
apps.push(detected);
136156
}
@@ -180,11 +200,21 @@ export function registerExternalAppsIpc(): void {
180200
return { success: false, error: "Application not found" };
181201
}
182202

203+
let isFile = false;
204+
try {
205+
const stat = await fs.stat(targetPath);
206+
isFile = stat.isFile();
207+
} catch {
208+
// if stat fails, assume it is a path that does not exist yet
209+
isFile = false;
210+
}
211+
183212
let command: string;
184-
if (appToOpen.command.includes("open -a")) {
185-
command = `${appToOpen.command} "${targetPath}"`;
213+
if (appToOpen.id === "finder" && isFile) {
214+
// for Finder with files, use -R to highlight the file in its parent folder
215+
command = `open -R "${targetPath}"`;
186216
} else {
187-
command = `${appToOpen.command} "${targetPath}"`;
217+
command = `open -a "${appToOpen.path}" "${targetPath}"`;
188218
}
189219

190220
await execAsync(command);

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,11 @@ class FileService {
124124

125125
for (const event of events) {
126126
state.pendingDirs.add(path.dirname(event.path));
127-
if (event.type === "update") {
127+
128+
// Handle both "update" and "create" events as file changes
129+
// Atomic writes (like the claude code Write tool) produce "create"
130+
// events because they write to a temp file then rename/move it
131+
if (event.type === "update" || event.type === "create") {
128132
state.pendingFiles.add(event.path);
129133
} else if (event.type === "delete") {
130134
state.pendingDeletes.add(event.path);

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

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ const execAsync = promisify(exec);
1313
const execFileAsync = promisify(execFile);
1414
const fsPromises = fs.promises;
1515

16+
const countFileLines = async (filePath: string): Promise<number> => {
17+
try {
18+
const content = await fsPromises.readFile(filePath, "utf-8");
19+
if (!content) return 0;
20+
21+
// Match git line counting: do not count trailing newline as extra line
22+
const lines = content.split("\n");
23+
return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length;
24+
} catch {
25+
return 0;
26+
}
27+
};
28+
1629
const getAllFilesInDirectory = async (
1730
directoryPath: string,
1831
basePath: string,
@@ -194,26 +207,63 @@ const getChangedFilesAgainstHead = async (
194207
const files: ChangedFile[] = [];
195208
const seenPaths = new Set<string>();
196209

197-
// Use git diff with -M to detect renames in working tree
198-
const { stdout: diffOutput } = await execAsync(
199-
"git diff -M --name-status HEAD",
200-
{ cwd: directoryPath },
201-
);
210+
// Run git commands in parallel
211+
const [nameStatusResult, numstatResult, statusResult] = await Promise.all([
212+
execAsync("git diff -M --name-status HEAD", { cwd: directoryPath }),
213+
execAsync("git diff -M --numstat HEAD", { cwd: directoryPath }),
214+
execAsync("git status --porcelain", { cwd: directoryPath }),
215+
]);
216+
217+
// Build line stats map from numstat output
218+
// Format: ADDED\tREMOVED\tPATH or for renames: ADDED\tREMOVED\tOLD_PATH => NEW_PATH
219+
const lineStats = new Map<string, { added: number; removed: number }>();
220+
for (const line of numstatResult.stdout
221+
.trim()
222+
.split("\n")
223+
.filter(Boolean)) {
224+
const parts = line.split("\t");
225+
if (parts.length >= 3) {
226+
const added = parts[0] === "-" ? 0 : parseInt(parts[0], 10) || 0;
227+
const removed = parts[1] === "-" ? 0 : parseInt(parts[1], 10) || 0;
228+
const filePath = parts.slice(2).join("\t");
229+
// For renames, numstat shows "old => new" - extract both paths
230+
if (filePath.includes(" => ")) {
231+
const renameParts = filePath.split(" => ");
232+
// Store under both old and new path for lookup
233+
lineStats.set(renameParts[0], { added, removed });
234+
lineStats.set(renameParts[1], { added, removed });
235+
} else {
236+
lineStats.set(filePath, { added, removed });
237+
}
238+
}
239+
}
202240

203-
for (const line of diffOutput.trim().split("\n").filter(Boolean)) {
204-
// Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
241+
// Parse name-status output for file status
242+
// Format: STATUS\tPATH or STATUS\tOLD_PATH\tNEW_PATH for renames
243+
for (const line of nameStatusResult.stdout
244+
.trim()
245+
.split("\n")
246+
.filter(Boolean)) {
205247
const parts = line.split("\t");
206248
const statusChar = parts[0][0]; // First char (ignore rename percentage like R100)
207249

208250
if (statusChar === "R" && parts.length >= 3) {
209251
// Rename: R100\told-path\tnew-path
210252
const originalPath = parts[1];
211253
const newPath = parts[2];
212-
files.push({ path: newPath, status: "renamed", originalPath });
254+
const stats = lineStats.get(newPath) || lineStats.get(originalPath);
255+
files.push({
256+
path: newPath,
257+
status: "renamed",
258+
originalPath,
259+
linesAdded: stats?.added,
260+
linesRemoved: stats?.removed,
261+
});
213262
seenPaths.add(newPath);
214263
seenPaths.add(originalPath);
215264
} else if (parts.length >= 2) {
216265
const filePath = parts[1];
266+
const stats = lineStats.get(filePath);
217267
let status: GitFileStatus;
218268
switch (statusChar) {
219269
case "D":
@@ -225,23 +275,22 @@ const getChangedFilesAgainstHead = async (
225275
default:
226276
status = "modified";
227277
}
228-
files.push({ path: filePath, status });
278+
files.push({
279+
path: filePath,
280+
status,
281+
linesAdded: stats?.added,
282+
linesRemoved: stats?.removed,
283+
});
229284
seenPaths.add(filePath);
230285
}
231286
}
232287

233288
// Add untracked files from git status
234-
const { stdout: statusOutput } = await execAsync("git status --porcelain", {
235-
cwd: directoryPath,
236-
});
237-
238-
for (const line of statusOutput.trim().split("\n").filter(Boolean)) {
289+
for (const line of statusResult.stdout.trim().split("\n").filter(Boolean)) {
239290
const statusCode = line.substring(0, 2);
240291
const filePath = line.substring(3);
241292

242-
// Only add untracked files not already seen
243293
if (statusCode === "??" && !seenPaths.has(filePath)) {
244-
// Check if it's a directory (git shows directories with trailing /)
245294
if (filePath.endsWith("/")) {
246295
const dirPath = filePath.slice(0, -1);
247296
try {
@@ -251,14 +300,28 @@ const getChangedFilesAgainstHead = async (
251300
);
252301
for (const file of dirFiles) {
253302
if (!seenPaths.has(file)) {
254-
files.push({ path: file, status: "untracked" });
303+
const lineCount = await countFileLines(
304+
path.join(directoryPath, file),
305+
);
306+
files.push({
307+
path: file,
308+
status: "untracked",
309+
linesAdded: lineCount || undefined,
310+
});
255311
}
256312
}
257313
} catch {
258314
// Directory might not exist or be inaccessible
259315
}
260316
} else {
261-
files.push({ path: filePath, status: "untracked" });
317+
const lineCount = await countFileLines(
318+
path.join(directoryPath, filePath),
319+
);
320+
files.push({
321+
path: filePath,
322+
status: "untracked",
323+
linesAdded: lineCount || undefined,
324+
});
262325
}
263326
}
264327
}

apps/array/src/renderer/features/auth/stores/authStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { electronStorage } from "@renderer/lib/electronStorage";
44
import { logger } from "@renderer/lib/logger";
55
import { queryClient } from "@renderer/lib/queryClient";
66
import type { CloudRegion } from "@shared/types/oauth";
7+
import { useNavigationStore } from "@stores/navigationStore";
78
import { create } from "zustand";
89
import { persist } from "zustand/middleware";
910
import {
@@ -369,6 +370,8 @@ export const useAuthStore = create<AuthState>()(
369370

370371
queryClient.clear();
371372

373+
useNavigationStore.getState().navigateToTaskInput();
374+
372375
set({
373376
oauthAccessToken: null,
374377
oauthRefreshToken: null,

apps/array/src/renderer/features/sessions/components/ChatBubble.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function ChatBubble({ variant, children }: ChatBubbleProps) {
1111

1212
return (
1313
<Box
14-
className={`mr-auto max-w-[95%] py-1 xl:max-w-[60%] [&>*:last-child]:mb-0 ${isUser ? "mt-4 rounded-xl rounded-bl-sm bg-gray-2 px-3 py-2" : ""}
14+
className={`mr-auto max-w-[95%] py-1 xl:max-w-[60%] [&>*:last-child]:mb-0 ${isUser ? "mt-4 rounded-xl rounded-bl-sm bg-accent-4 px-3 py-2" : ""}
1515
`}
1616
>
1717
{children}

apps/array/src/renderer/features/sessions/components/MessageEditor.tsx

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ export const MessageEditor = forwardRef<
430430
onClick={handleContainerClick}
431431
style={{ cursor: "text" }}
432432
>
433-
<Box className="max-h-[200px] flex-1 overflow-y-auto font-mono text-sm">
433+
<Box className="max-h-[200px] min-h-[30px] flex-1 overflow-y-auto font-mono text-sm">
434434
<EditorContent editor={editor} />
435435
</Box>
436436
<Flex justify="between" align="center">
@@ -448,32 +448,48 @@ export const MessageEditor = forwardRef<
448448
color="gray"
449449
onClick={() => fileInputRef.current?.click()}
450450
disabled={disabled}
451+
title="Attach file"
452+
style={{ marginLeft: "0px" }}
451453
>
452-
<Paperclip size={14} />
454+
<Paperclip size={14} weight="bold" />
453455
</IconButton>
454456
</Tooltip>
455-
{isLoading && onCancel ? (
456-
<IconButton
457-
size="1"
458-
variant="soft"
459-
color="red"
460-
onClick={onCancel}
461-
title="Stop"
462-
>
463-
<Stop size={14} weight="fill" />
464-
</IconButton>
465-
) : (
466-
<IconButton
467-
size="1"
468-
variant="solid"
469-
color="orange"
470-
onClick={handleSubmit}
471-
disabled={disabled || isEmpty}
472-
title="Send (Enter)"
473-
>
474-
<ArrowUp size={14} weight="bold" />
475-
</IconButton>
476-
)}
457+
<Flex gap="4" align="center">
458+
{isLoading && onCancel ? (
459+
<Tooltip content="Stop">
460+
<IconButton
461+
size="1"
462+
variant="soft"
463+
color="red"
464+
onClick={onCancel}
465+
title="Stop"
466+
>
467+
<Stop size={14} weight="fill" />
468+
</IconButton>
469+
</Tooltip>
470+
) : (
471+
<Tooltip
472+
content={
473+
disabled || isEmpty ? "Enter a message" : "Send message"
474+
}
475+
>
476+
<IconButton
477+
size="1"
478+
variant="solid"
479+
onClick={handleSubmit}
480+
disabled={disabled || isEmpty}
481+
loading={isLoading}
482+
style={{
483+
backgroundColor:
484+
disabled || isEmpty ? "var(--accent-a4)" : undefined,
485+
color: disabled || isEmpty ? "var(--accent-8)" : undefined,
486+
}}
487+
>
488+
<ArrowUp size={14} weight="bold" />
489+
</IconButton>
490+
</Tooltip>
491+
)}
492+
</Flex>
477493
</Flex>
478494
</Flex>
479495
);

apps/array/src/renderer/features/sessions/components/SessionView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ export function SessionView({
475475
const shouldCollapse = turn.isComplete && collapsibleMessages.length > 0;
476476

477477
return (
478-
<Box className="flex flex-col gap-2">
478+
<Box className="flex flex-col gap-4">
479479
<UserMessage content={turn.userMessage.content} />
480480
{shouldCollapse ? (
481481
<>
@@ -588,7 +588,7 @@ export function SessionView({
588588
renderItem={renderTurn}
589589
autoScrollToBottom
590590
className="flex-1 p-4"
591-
gap={8}
591+
gap={24}
592592
footer={
593593
<>
594594
{isPromptPending && (

0 commit comments

Comments
 (0)