Skip to content

Commit 0481eab

Browse files
authored
feat: Add a new open file tool in the changed file item toolbar (#214)
1 parent d84914a commit 0481eab

File tree

3 files changed

+180
-51
lines changed

3 files changed

+180
-51
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/renderer/features/task-detail/components/ChangesPanel.tsx

Lines changed: 129 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { PanelMessage } from "@components/ui/PanelMessage";
22
import { isDiffTabActiveInTree, usePanelLayoutStore } from "@features/panels";
33
import { useTaskData } from "@features/task-detail/hooks/useTaskData";
4-
import { ArrowCounterClockwiseIcon, FileIcon } from "@phosphor-icons/react";
5-
import { Badge, Box, Flex, IconButton, Text, Tooltip } from "@radix-ui/themes";
4+
import {
5+
ArrowCounterClockwiseIcon,
6+
CodeIcon,
7+
CopyIcon,
8+
FileIcon,
9+
FilePlus,
10+
} from "@phosphor-icons/react";
11+
import {
12+
Badge,
13+
Box,
14+
DropdownMenu,
15+
Flex,
16+
IconButton,
17+
Text,
18+
Tooltip,
19+
} from "@radix-ui/themes";
620
import type { ChangedFile, GitFileStatus, Task } from "@shared/types";
21+
import { useExternalAppsStore } from "@stores/externalAppsStore";
722
import { useQuery, useQueryClient } from "@tanstack/react-query";
823
import { showMessageBox } from "@utils/dialog";
924
import { handleExternalAppAction } from "@utils/handleExternalAppAction";
25+
import { useState } from "react";
1026
import {
1127
selectWorktreePath,
1228
useWorkspaceStore,
@@ -92,7 +108,16 @@ function ChangedFileItem({
92108
(state) => state.closeDiffTabsForFile,
93109
);
94110
const queryClient = useQueryClient();
111+
const { detectedApps } = useExternalAppsStore();
112+
113+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
114+
const [isHovered, setIsHovered] = useState(false);
115+
116+
// show toolbar when hovered OR when dropdown is open
117+
const isToolbarVisible = isHovered || isDropdownOpen;
118+
95119
const fileName = file.path.split("/").pop() || file.path;
120+
const fullPath = `${repoPath}/${file.path}`;
96121
const indicator = getStatusIndicator(file.status);
97122

98123
const handleClick = () => {
@@ -101,14 +126,30 @@ function ChangedFileItem({
101126

102127
const handleContextMenu = async (e: React.MouseEvent) => {
103128
e.preventDefault();
104-
const fullPath = `${repoPath}/${file.path}`;
105129
const result = await window.electronAPI.showFileContextMenu(fullPath);
106130

107131
if (!result.action) return;
108132

109133
await handleExternalAppAction(result.action, fullPath, fileName);
110134
};
111135

136+
const handleOpenWith = async (appId: string) => {
137+
await handleExternalAppAction(
138+
{ type: "open-in-app", appId },
139+
fullPath,
140+
fileName,
141+
);
142+
143+
// blur active element to dismiss any open tooltip
144+
if (document.activeElement instanceof HTMLElement) {
145+
document.activeElement.blur();
146+
}
147+
};
148+
149+
const handleCopyPath = async () => {
150+
await handleExternalAppAction({ type: "copy-path" }, fullPath, fileName);
151+
};
152+
112153
const handleDiscard = async (e: React.MouseEvent) => {
113154
e.preventDefault();
114155

@@ -147,7 +188,13 @@ function ChangedFileItem({
147188
gap="1"
148189
onClick={handleClick}
149190
onContextMenu={handleContextMenu}
150-
className={`group ${isActive ? "border-accent-8 border-y bg-accent-4" : "border-transparent border-y hover:bg-gray-3"}`}
191+
onMouseEnter={() => setIsHovered(true)}
192+
onMouseLeave={() => setIsHovered(false)}
193+
className={
194+
isActive
195+
? "border-accent-8 border-y bg-accent-4"
196+
: "border-transparent border-y hover:bg-gray-3"
197+
}
151198
style={{
152199
cursor: "pointer",
153200
whiteSpace: "nowrap",
@@ -187,11 +234,10 @@ function ChangedFileItem({
187234
{file.originalPath ? `${file.originalPath}${file.path}` : file.path}
188235
</Text>
189236

190-
{hasLineStats && (
237+
{hasLineStats && !isToolbarVisible && (
191238
<Flex
192239
align="center"
193240
gap="1"
194-
className="group-hover:hidden"
195241
style={{ flexShrink: 0, fontSize: "10px", fontFamily: "monospace" }}
196242
>
197243
{(file.linesAdded ?? 0) > 0 && (
@@ -203,31 +249,84 @@ function ChangedFileItem({
203249
</Flex>
204250
)}
205251

206-
<Flex
207-
align="center"
208-
gap="1"
209-
className="hidden group-hover:flex"
210-
style={{ flexShrink: 0 }}
211-
>
212-
<Tooltip content="Discard changes">
213-
<IconButton
214-
size="1"
215-
variant="ghost"
216-
color="gray"
217-
onClick={handleDiscard}
218-
style={{
219-
flexShrink: 0,
220-
width: "18px",
221-
height: "18px",
222-
padding: 0,
223-
marginLeft: "2px",
224-
marginRight: "2px",
225-
}}
252+
{isToolbarVisible && (
253+
<Flex align="center" gap="1" style={{ flexShrink: 0 }}>
254+
<Tooltip content="Discard changes">
255+
<IconButton
256+
size="1"
257+
variant="ghost"
258+
color="gray"
259+
onClick={handleDiscard}
260+
style={{
261+
flexShrink: 0,
262+
width: "18px",
263+
height: "18px",
264+
padding: 0,
265+
marginLeft: "2px",
266+
marginRight: "2px",
267+
}}
268+
>
269+
<ArrowCounterClockwiseIcon size={12} />
270+
</IconButton>
271+
</Tooltip>
272+
273+
<DropdownMenu.Root
274+
open={isDropdownOpen}
275+
onOpenChange={setIsDropdownOpen}
226276
>
227-
<ArrowCounterClockwiseIcon size={12} />
228-
</IconButton>
229-
</Tooltip>
230-
</Flex>
277+
<Tooltip content="Open file">
278+
<DropdownMenu.Trigger>
279+
<IconButton
280+
size="1"
281+
variant="ghost"
282+
color="gray"
283+
onClick={(e) => e.stopPropagation()}
284+
style={{
285+
flexShrink: 0,
286+
width: "18px",
287+
height: "18px",
288+
padding: 0,
289+
}}
290+
>
291+
<FilePlus size={12} weight="regular" />
292+
</IconButton>
293+
</DropdownMenu.Trigger>
294+
</Tooltip>
295+
<DropdownMenu.Content size="1" align="end">
296+
{detectedApps
297+
.filter((app) => app.type !== "terminal")
298+
.map((app) => (
299+
<DropdownMenu.Item
300+
key={app.id}
301+
onSelect={() => handleOpenWith(app.id)}
302+
>
303+
<Flex align="center" gap="2">
304+
{app.icon ? (
305+
<img
306+
src={app.icon}
307+
width={16}
308+
height={16}
309+
alt=""
310+
style={{ borderRadius: "2px" }}
311+
/>
312+
) : (
313+
<CodeIcon size={16} weight="regular" />
314+
)}
315+
<Text size="1">{app.name}</Text>
316+
</Flex>
317+
</DropdownMenu.Item>
318+
))}
319+
<DropdownMenu.Separator />
320+
<DropdownMenu.Item onSelect={handleCopyPath}>
321+
<Flex align="center" gap="2">
322+
<CopyIcon size={16} weight="regular" />
323+
<Text size="1">Copy Path</Text>
324+
</Flex>
325+
</DropdownMenu.Item>
326+
</DropdownMenu.Content>
327+
</DropdownMenu.Root>
328+
</Flex>
329+
)}
231330

232331
<Badge
233332
size="1"

apps/array/src/shared/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export interface ChangedFile {
189189
}
190190

191191
// External apps detection types
192-
export type ExternalAppType = "editor" | "terminal";
192+
export type ExternalAppType = "editor" | "terminal" | "file-manager";
193193

194194
export interface DetectedApplication {
195195
id: string; // "vscode", "cursor", "iterm"

0 commit comments

Comments
 (0)