raycast-rsync-extension 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.eslintrc.js +18 -0
  2. package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
  3. package/.github/dependabot.yml +35 -0
  4. package/.github/workflows/ci.yml +105 -0
  5. package/.github/workflows/publish.yml +269 -0
  6. package/.github/workflows/update-copyright-year.yml +70 -0
  7. package/CHANGELOG.md +7 -0
  8. package/LICENSE +21 -0
  9. package/README.md +81 -0
  10. package/assets/icon.png +0 -0
  11. package/eslint.config.js +23 -0
  12. package/metadata/browse-remote-path.png +0 -0
  13. package/metadata/browse-remote.png +0 -0
  14. package/metadata/download-local-path.png +0 -0
  15. package/metadata/download-remote-path.png +0 -0
  16. package/metadata/extension.png +0 -0
  17. package/metadata/upload-local-path.png +0 -0
  18. package/metadata/upload-remote-path.png +0 -0
  19. package/metadata/upload-search-host.png +0 -0
  20. package/package.json +93 -0
  21. package/src/__mocks__/raycast-api.ts +84 -0
  22. package/src/browse.tsx +378 -0
  23. package/src/components/FileList.test.tsx +73 -0
  24. package/src/components/FileList.tsx +61 -0
  25. package/src/download.tsx +353 -0
  26. package/src/e2e/browse.e2e.test.ts +295 -0
  27. package/src/e2e/download.e2e.test.ts +193 -0
  28. package/src/e2e/error-handling.e2e.test.ts +292 -0
  29. package/src/e2e/rsync-options.e2e.test.ts +348 -0
  30. package/src/e2e/upload.e2e.test.ts +207 -0
  31. package/src/index.tsx +21 -0
  32. package/src/test-setup.ts +1 -0
  33. package/src/types/server.ts +60 -0
  34. package/src/upload.tsx +404 -0
  35. package/src/utils/__tests__/sshConfig.test.ts +352 -0
  36. package/src/utils/__tests__/validation.test.ts +139 -0
  37. package/src/utils/preferences.ts +24 -0
  38. package/src/utils/rsync.test.ts +490 -0
  39. package/src/utils/rsync.ts +517 -0
  40. package/src/utils/shellEscape.test.ts +98 -0
  41. package/src/utils/shellEscape.ts +36 -0
  42. package/src/utils/ssh.test.ts +209 -0
  43. package/src/utils/ssh.ts +187 -0
  44. package/src/utils/sshConfig.test.ts +191 -0
  45. package/src/utils/sshConfig.ts +212 -0
  46. package/src/utils/validation.test.ts +224 -0
  47. package/src/utils/validation.ts +115 -0
  48. package/tsconfig.json +27 -0
  49. package/vitest.config.ts +8 -0
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "https://www.raycast.com/schemas/extension.json",
3
+ "name": "raycast-rsync-extension",
4
+ "version": "1.0.1",
5
+ "title": "Rsync File Transfer",
6
+ "description": "Transfer files between local and remote servers using rsync with SSH config integration",
7
+ "icon": "icon.png",
8
+ "author": "dytsou",
9
+ "categories": [
10
+ "Developer Tools",
11
+ "Productivity"
12
+ ],
13
+ "license": "MIT",
14
+ "commands": [
15
+ {
16
+ "name": "upload",
17
+ "title": "Upload Files Via Rsync",
18
+ "description": "Upload local files to a remote server using rsync",
19
+ "mode": "view"
20
+ },
21
+ {
22
+ "name": "download",
23
+ "title": "Download Files Via Rsync",
24
+ "description": "Download files from a remote server using rsync",
25
+ "mode": "view"
26
+ },
27
+ {
28
+ "name": "browse",
29
+ "title": "Browse Remote Files",
30
+ "description": "Browse and list files on a remote server",
31
+ "mode": "view"
32
+ }
33
+ ],
34
+ "preferences": [
35
+ {
36
+ "name": "rsyncHumanReadable",
37
+ "type": "checkbox",
38
+ "title": "Human-readable file sizes",
39
+ "label": "Human-readable file sizes (-h)",
40
+ "description": "Display file sizes in KB, MB, GB format",
41
+ "default": true,
42
+ "required": false
43
+ },
44
+ {
45
+ "name": "rsyncProgress",
46
+ "type": "checkbox",
47
+ "title": "Show progress",
48
+ "label": "Show progress (-P)",
49
+ "description": "Show transfer progress and support partial transfers",
50
+ "default": true,
51
+ "required": false
52
+ },
53
+ {
54
+ "name": "rsyncDelete",
55
+ "type": "checkbox",
56
+ "title": "Delete extraneous files",
57
+ "label": "Delete extraneous files (--delete)",
58
+ "description": "Delete files in destination that don't exist in source (use with caution)",
59
+ "default": false,
60
+ "required": false
61
+ }
62
+ ],
63
+ "dependencies": {
64
+ "@raycast/api": "^1.65.0",
65
+ "@raycast/utils": "^1.19.1"
66
+ },
67
+ "devDependencies": {
68
+ "@raycast/eslint-config": "^2.1.1",
69
+ "@types/node": "^20.19.30",
70
+ "@types/react": "19.0.10",
71
+ "@vitest/ui": "^4.0.17",
72
+ "eslint": "^9.39.2",
73
+ "prettier": "^3.8.0",
74
+ "react": "^19.0.0",
75
+ "typescript": "^5.2.2",
76
+ "vitest": "^4.0.17"
77
+ },
78
+ "overrides": {
79
+ "@types/react": "19.0.10"
80
+ },
81
+ "scripts": {
82
+ "build": "ray build -e dist",
83
+ "dev": "ray develop",
84
+ "fix-lint": "ray lint --fix",
85
+ "lint": "ray lint",
86
+ "publish": "npx @raycast/api@latest publish",
87
+ "test": "vitest --run",
88
+ "test:watch": "vitest",
89
+ "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
90
+ "format:check": "prettier --check \"**/*.{js,jsx,ts,tsx,json,css,md}\"",
91
+ "publish:npm": "npm publish --access public"
92
+ }
93
+ }
@@ -0,0 +1,84 @@
1
+ import React from "react";
2
+ import { vi } from "vitest";
3
+
4
+ export const List = Object.assign(
5
+ ({ children, ...props }: any) =>
6
+ React.createElement("div", { "data-testid": "list", ...props }, children),
7
+ {
8
+ Item: ({ title, subtitle, actions, ...props }: any) =>
9
+ React.createElement(
10
+ "div",
11
+ {
12
+ "data-testid": "list-item",
13
+ "data-title": title,
14
+ "data-subtitle": subtitle,
15
+ ...props,
16
+ },
17
+ actions,
18
+ ),
19
+ EmptyView: ({ title, description }: any) =>
20
+ React.createElement("div", {
21
+ "data-testid": "empty-view",
22
+ "data-title": title,
23
+ "data-description": description,
24
+ }),
25
+ },
26
+ );
27
+
28
+ export const Form = Object.assign(
29
+ ({ children, actions }: any) =>
30
+ React.createElement("div", { "data-testid": "form" }, children, actions),
31
+ {
32
+ Description: ({ title, text }: any) =>
33
+ React.createElement("div", {
34
+ "data-testid": "form-description",
35
+ "data-title": title,
36
+ "data-text": text,
37
+ }),
38
+ TextField: ({ id, title, value, onChange }: any) =>
39
+ React.createElement("input", {
40
+ "data-testid": `form-field-${id}`,
41
+ "data-title": title,
42
+ value: value,
43
+ onChange: (e: any) => onChange?.(e.target.value),
44
+ }),
45
+ Separator: () =>
46
+ React.createElement("hr", { "data-testid": "form-separator" }),
47
+ },
48
+ );
49
+
50
+ export const ActionPanel = ({ children }: any) =>
51
+ React.createElement("div", { "data-testid": "action-panel" }, children);
52
+
53
+ export const Action = ({ title, onAction }: any) =>
54
+ React.createElement(
55
+ "button",
56
+ {
57
+ "data-testid": `action-${title?.toLowerCase().replace(/\s+/g, "-")}`,
58
+ onClick: onAction,
59
+ },
60
+ title,
61
+ );
62
+
63
+ export const showToast = vi.fn();
64
+ export const getSelectedFinderItems = vi.fn();
65
+ export const getPreferenceValues = vi.fn();
66
+
67
+ export const Icon = {
68
+ CheckCircle: "check-circle",
69
+ ArrowClockwise: "arrow-clockwise",
70
+ Upload: "upload",
71
+ Download: "download",
72
+ Document: "document",
73
+ ArrowLeft: "arrow-left",
74
+ MagnifyingGlass: "magnifying-glass",
75
+ Trash: "trash",
76
+ };
77
+
78
+ export const Toast = {
79
+ Style: {
80
+ Success: "success",
81
+ Failure: "failure",
82
+ Animated: "animated",
83
+ },
84
+ };
package/src/browse.tsx ADDED
@@ -0,0 +1,378 @@
1
+ import {
2
+ List,
3
+ ActionPanel,
4
+ Action,
5
+ Form,
6
+ showToast,
7
+ Toast,
8
+ Icon,
9
+ useNavigation,
10
+ popToRoot,
11
+ Clipboard,
12
+ } from "@raycast/api";
13
+ import React, { useState, useEffect } from "react";
14
+ import { parseSSHConfig } from "./utils/sshConfig";
15
+ import { executeRemoteLs } from "./utils/ssh";
16
+ import { validateRemotePath, validateHostConfig } from "./utils/validation";
17
+ import { SSHHostConfig, RemoteFile } from "./types/server";
18
+
19
+ /**
20
+ * Main browse command component
21
+ * Displays list of SSH hosts from config file
22
+ */
23
+ export default function Command() {
24
+ const [hosts, setHosts] = useState<SSHHostConfig[]>([]);
25
+ const [isLoading, setIsLoading] = useState(true);
26
+ const [error, setError] = useState<string | null>(null);
27
+
28
+ useEffect(() => {
29
+ loadHosts();
30
+ }, []);
31
+
32
+ async function loadHosts() {
33
+ try {
34
+ const parsedHosts = parseSSHConfig();
35
+
36
+ if (parsedHosts.length === 0) {
37
+ const errorMsg = "No host entries found in SSH config file";
38
+ setError(errorMsg);
39
+ console.warn("SSH config parsed but no hosts found");
40
+ } else {
41
+ setHosts(parsedHosts);
42
+ console.log(`Loaded ${parsedHosts.length} SSH host(s)`);
43
+ }
44
+ } catch (err) {
45
+ const errorMessage =
46
+ err instanceof Error ? err.message : "Failed to parse SSH config";
47
+ console.error("Error loading SSH hosts:", err);
48
+ setError(errorMessage);
49
+ await showToast({
50
+ style: Toast.Style.Failure,
51
+ title: "Error Loading SSH Config",
52
+ message: errorMessage,
53
+ });
54
+ } finally {
55
+ setIsLoading(false);
56
+ }
57
+ }
58
+
59
+ if (error) {
60
+ return (
61
+ <List>
62
+ <List.EmptyView title="Error Loading SSH Config" description={error} />
63
+ </List>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <List isLoading={isLoading} searchBarPlaceholder="Search hosts...">
69
+ {hosts.map((host: SSHHostConfig) => (
70
+ <List.Item
71
+ key={host.host}
72
+ title={host.host}
73
+ subtitle={host.hostName}
74
+ accessories={[
75
+ { text: host.user ? `User: ${host.user}` : "" },
76
+ { text: host.port ? `Port: ${host.port}` : "" },
77
+ ]}
78
+ actions={
79
+ <ActionPanel>
80
+ <Action.Push
81
+ title="Browse Remote Files"
82
+ target={<RemotePathForm hostConfig={host} />}
83
+ />
84
+ </ActionPanel>
85
+ }
86
+ />
87
+ ))}
88
+ </List>
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Remote path input form
94
+ * Allows user to specify path to browse on remote server
95
+ */
96
+ function RemotePathForm({ hostConfig }: { hostConfig: SSHHostConfig }) {
97
+ const [remotePath, setRemotePath] = useState<string>("~");
98
+ const [remotePathError, setRemotePathError] = useState<string | undefined>();
99
+ const { push } = useNavigation();
100
+
101
+ async function handleSubmit(values: { remotePath: string }) {
102
+ const remotePathValue = values.remotePath.trim() || "~";
103
+
104
+ // Validate remote path
105
+ const remoteValidation = validateRemotePath(remotePathValue);
106
+ if (!remoteValidation.valid) {
107
+ console.error("Remote path validation failed:", remoteValidation.error);
108
+ setRemotePathError(remoteValidation.error);
109
+ await showToast({
110
+ style: Toast.Style.Failure,
111
+ title: "Invalid Remote Path",
112
+ message: remoteValidation.error || "The remote path format is invalid",
113
+ });
114
+ return;
115
+ }
116
+
117
+ // Validate host config
118
+ const hostValidation = validateHostConfig(hostConfig);
119
+ if (!hostValidation.valid) {
120
+ console.error("Host config validation failed:", hostValidation.error);
121
+ await showToast({
122
+ style: Toast.Style.Failure,
123
+ title: "Invalid Host Configuration",
124
+ message:
125
+ hostValidation.error ||
126
+ "The host configuration is incomplete or invalid",
127
+ });
128
+ return;
129
+ }
130
+
131
+ // Navigate to file list after successful validation
132
+ push(
133
+ <RemoteFileListLoader
134
+ hostConfig={hostConfig}
135
+ remotePath={remotePathValue}
136
+ />,
137
+ );
138
+ }
139
+
140
+ return (
141
+ <Form
142
+ actions={
143
+ <ActionPanel>
144
+ <Action.SubmitForm title="Browse" onSubmit={handleSubmit} />
145
+ <Action.Push
146
+ title="Browse Directory"
147
+ target={
148
+ <RemoteFileListLoader
149
+ hostConfig={hostConfig}
150
+ remotePath={remotePath}
151
+ />
152
+ }
153
+ />
154
+ </ActionPanel>
155
+ }
156
+ >
157
+ <Form.TextField
158
+ id="remotePath"
159
+ title="Remote Path"
160
+ placeholder="~ or /path/to/directory"
161
+ value={remotePath}
162
+ onChange={(value: string) => {
163
+ setRemotePath(value);
164
+ setRemotePathError(undefined);
165
+ }}
166
+ error={remotePathError}
167
+ info="Enter the directory path on the remote server to browse"
168
+ />
169
+ <Form.Description
170
+ title="Host Details"
171
+ text={`Browsing: ${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
172
+ />
173
+ {hostConfig.user && (
174
+ <Form.Description title="User" text={hostConfig.user} />
175
+ )}
176
+ {hostConfig.port && (
177
+ <Form.Description title="Port" text={hostConfig.port.toString()} />
178
+ )}
179
+ </Form>
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Remote file list loader component
185
+ * Loads files from remote server and displays them
186
+ */
187
+ function RemoteFileListLoader({
188
+ hostConfig,
189
+ remotePath,
190
+ }: {
191
+ hostConfig: SSHHostConfig;
192
+ remotePath: string;
193
+ }) {
194
+ const [files, setFiles] = useState<RemoteFile[]>([]);
195
+ const [isLoading, setIsLoading] = useState(true);
196
+ const [error, setError] = useState<string | null>(null);
197
+
198
+ useEffect(() => {
199
+ loadRemoteFiles();
200
+ }, [remotePath]);
201
+
202
+ async function loadRemoteFiles() {
203
+ setIsLoading(true);
204
+ setError(null);
205
+
206
+ console.log("Loading remote files:", {
207
+ host: hostConfig.host,
208
+ remotePath,
209
+ });
210
+
211
+ try {
212
+ const remoteFiles = await executeRemoteLs(hostConfig, remotePath);
213
+ setFiles(remoteFiles);
214
+
215
+ if (remoteFiles.length === 0) {
216
+ await showToast({
217
+ style: Toast.Style.Success,
218
+ title: "Directory is empty",
219
+ });
220
+ }
221
+ } catch (err) {
222
+ const errorMessage =
223
+ err instanceof Error ? err.message : "Unknown error occurred";
224
+ console.error("Browse error:", err);
225
+ setError(errorMessage);
226
+ await showToast({
227
+ style: Toast.Style.Failure,
228
+ title: "Failed to List Files",
229
+ message: errorMessage,
230
+ });
231
+ } finally {
232
+ setIsLoading(false);
233
+ }
234
+ }
235
+
236
+ if (error) {
237
+ return (
238
+ <List>
239
+ <List.EmptyView
240
+ title="Error Loading Files"
241
+ description={error}
242
+ actions={
243
+ <ActionPanel>
244
+ <Action title="Retry" onAction={loadRemoteFiles} />
245
+ </ActionPanel>
246
+ }
247
+ />
248
+ </List>
249
+ );
250
+ }
251
+
252
+ return (
253
+ <RemoteFileList
254
+ hostConfig={hostConfig}
255
+ remotePath={remotePath}
256
+ files={files}
257
+ isLoading={isLoading}
258
+ />
259
+ );
260
+ }
261
+
262
+ /**
263
+ * Remote file list view
264
+ * Displays files and directories from remote server
265
+ */
266
+ function RemoteFileList({
267
+ hostConfig,
268
+ remotePath,
269
+ files,
270
+ isLoading,
271
+ }: {
272
+ hostConfig: SSHHostConfig;
273
+ remotePath: string;
274
+ files: RemoteFile[];
275
+ isLoading?: boolean;
276
+ }) {
277
+ return (
278
+ <List searchBarPlaceholder="Search files..." isLoading={isLoading}>
279
+ {files.map((file, index) => (
280
+ <List.Item
281
+ key={`${file.name}-${index}`}
282
+ title={file.name}
283
+ icon={file.isDirectory ? Icon.Folder : Icon.Document}
284
+ accessories={[
285
+ { text: file.size || "" },
286
+ { text: file.permissions || "" },
287
+ ]}
288
+ actions={
289
+ <ActionPanel>
290
+ {file.isDirectory ? (
291
+ <>
292
+ <Action.Push
293
+ title="Open Directory"
294
+ target={
295
+ <RemoteFileListLoader
296
+ hostConfig={hostConfig}
297
+ remotePath={
298
+ remotePath.endsWith("/")
299
+ ? `${remotePath}${file.name}`
300
+ : `${remotePath}/${file.name}`
301
+ }
302
+ />
303
+ }
304
+ />
305
+ <Action
306
+ title="Copy Path"
307
+ icon={Icon.Clipboard}
308
+ shortcut={{ modifiers: ["cmd"], key: "c" }}
309
+ onAction={async () => {
310
+ const pathToCopy = remotePath.endsWith("/")
311
+ ? `${remotePath}${file.name}`
312
+ : `${remotePath}/${file.name}`;
313
+ await Clipboard.copy(pathToCopy);
314
+ await showToast({
315
+ style: Toast.Style.Success,
316
+ title: "Path Copied",
317
+ message: "Path copied to clipboard",
318
+ });
319
+ await popToRoot();
320
+ }}
321
+ />
322
+ <Action
323
+ title="Copy Name"
324
+ icon={Icon.Clipboard}
325
+ shortcut={{ modifiers: ["cmd", "shift"], key: "c" }}
326
+ onAction={async () => {
327
+ await Clipboard.copy(file.name);
328
+ await showToast({
329
+ style: Toast.Style.Success,
330
+ title: "Name Copied",
331
+ message: "Name copied to clipboard",
332
+ });
333
+ await popToRoot();
334
+ }}
335
+ />
336
+ </>
337
+ ) : (
338
+ <>
339
+ <Action
340
+ title="Copy Path"
341
+ icon={Icon.Clipboard}
342
+ shortcut={{ modifiers: ["cmd"], key: "c" }}
343
+ onAction={async () => {
344
+ const pathToCopy = remotePath.endsWith("/")
345
+ ? `${remotePath}${file.name}`
346
+ : `${remotePath}/${file.name}`;
347
+ await Clipboard.copy(pathToCopy);
348
+ await showToast({
349
+ style: Toast.Style.Success,
350
+ title: "Path Copied",
351
+ message: "Path copied to clipboard",
352
+ });
353
+ await popToRoot();
354
+ }}
355
+ />
356
+ <Action
357
+ title="Copy Name"
358
+ icon={Icon.Clipboard}
359
+ shortcut={{ modifiers: ["cmd", "shift"], key: "c" }}
360
+ onAction={async () => {
361
+ await Clipboard.copy(file.name);
362
+ await showToast({
363
+ style: Toast.Style.Success,
364
+ title: "Name Copied",
365
+ message: "Name copied to clipboard",
366
+ });
367
+ await popToRoot();
368
+ }}
369
+ />
370
+ </>
371
+ )}
372
+ </ActionPanel>
373
+ }
374
+ />
375
+ ))}
376
+ </List>
377
+ );
378
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { statSync } from "fs";
3
+
4
+ // Helper function to test file icon logic
5
+ function getFileIcon(filePath: string): "folder" | "document" {
6
+ try {
7
+ const stats = statSync(filePath);
8
+ return stats.isDirectory() ? "folder" : "document";
9
+ } catch {
10
+ return "document";
11
+ }
12
+ }
13
+
14
+ // Helper function to test file size formatting logic
15
+ function getFileSize(filePath: string): string {
16
+ try {
17
+ const stats = statSync(filePath);
18
+ if (stats.isDirectory()) {
19
+ return "Directory";
20
+ }
21
+ const bytes = stats.size;
22
+ if (bytes < 1024) return `${bytes} B`;
23
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
24
+ if (bytes < 1024 * 1024 * 1024)
25
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
26
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
27
+ } catch {
28
+ return "Unknown";
29
+ }
30
+ }
31
+
32
+ describe("FileList Component Logic", () => {
33
+ describe("getFileIcon", () => {
34
+ it("should return document icon for non-existent files", () => {
35
+ const result = getFileIcon("/nonexistent/path/file.txt");
36
+ expect(result).toBe("document");
37
+ });
38
+ });
39
+
40
+ describe("getFileSize", () => {
41
+ it("should return Unknown for non-existent files", () => {
42
+ const result = getFileSize("/nonexistent/path/file.txt");
43
+ expect(result).toBe("Unknown");
44
+ });
45
+
46
+ it("should format bytes correctly", () => {
47
+ // Test the formatting logic with mock data
48
+ const testCases = [
49
+ { bytes: 500, expected: "500 B" },
50
+ { bytes: 1024, expected: "1.00 KB" },
51
+ { bytes: 1536, expected: "1.50 KB" },
52
+ { bytes: 1048576, expected: "1.00 MB" },
53
+ { bytes: 1572864, expected: "1.50 MB" },
54
+ { bytes: 1073741824, expected: "1.00 GB" },
55
+ { bytes: 2147483648, expected: "2.00 GB" },
56
+ ];
57
+
58
+ testCases.forEach(({ bytes, expected }) => {
59
+ let result: string;
60
+ if (bytes < 1024) {
61
+ result = `${bytes} B`;
62
+ } else if (bytes < 1024 * 1024) {
63
+ result = `${(bytes / 1024).toFixed(2)} KB`;
64
+ } else if (bytes < 1024 * 1024 * 1024) {
65
+ result = `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
66
+ } else {
67
+ result = `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
68
+ }
69
+ expect(result).toBe(expected);
70
+ });
71
+ });
72
+ });
73
+ });
@@ -0,0 +1,61 @@
1
+ import { List, ActionPanel, Action, Icon } from "@raycast/api";
2
+ import { statSync } from "fs";
3
+ import { basename } from "path";
4
+ import React from "react";
5
+
6
+ interface FileListProps {
7
+ files: string[];
8
+ onRemove: (filePath: string) => void;
9
+ }
10
+
11
+ export function FileList({ files, onRemove }: FileListProps) {
12
+ const getFileIcon = (filePath: string): Icon => {
13
+ try {
14
+ const stats = statSync(filePath);
15
+ return stats.isDirectory() ? Icon.Folder : Icon.Document;
16
+ } catch {
17
+ return Icon.Document;
18
+ }
19
+ };
20
+
21
+ const getFileSize = (filePath: string): string => {
22
+ try {
23
+ const stats = statSync(filePath);
24
+ if (stats.isDirectory()) {
25
+ return "Directory";
26
+ }
27
+ const bytes = stats.size;
28
+ if (bytes < 1024) return `${bytes} B`;
29
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
30
+ if (bytes < 1024 * 1024 * 1024)
31
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
32
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
33
+ } catch {
34
+ return "Unknown";
35
+ }
36
+ };
37
+
38
+ return (
39
+ <List>
40
+ {files.map((filePath) => (
41
+ <List.Item
42
+ key={filePath}
43
+ title={basename(filePath)}
44
+ subtitle={getFileSize(filePath)}
45
+ icon={getFileIcon(filePath)}
46
+ accessories={[{ text: filePath }]}
47
+ actions={
48
+ <ActionPanel>
49
+ <Action
50
+ title="Remove"
51
+ icon={Icon.Trash}
52
+ onAction={() => onRemove(filePath)}
53
+ style={Action.Style.Destructive}
54
+ />
55
+ </ActionPanel>
56
+ }
57
+ />
58
+ ))}
59
+ </List>
60
+ );
61
+ }