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.
- package/.eslintrc.js +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +14 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +105 -0
- package/.github/workflows/publish.yml +269 -0
- package/.github/workflows/update-copyright-year.yml +70 -0
- package/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/assets/icon.png +0 -0
- package/eslint.config.js +23 -0
- package/metadata/browse-remote-path.png +0 -0
- package/metadata/browse-remote.png +0 -0
- package/metadata/download-local-path.png +0 -0
- package/metadata/download-remote-path.png +0 -0
- package/metadata/extension.png +0 -0
- package/metadata/upload-local-path.png +0 -0
- package/metadata/upload-remote-path.png +0 -0
- package/metadata/upload-search-host.png +0 -0
- package/package.json +93 -0
- package/src/__mocks__/raycast-api.ts +84 -0
- package/src/browse.tsx +378 -0
- package/src/components/FileList.test.tsx +73 -0
- package/src/components/FileList.tsx +61 -0
- package/src/download.tsx +353 -0
- package/src/e2e/browse.e2e.test.ts +295 -0
- package/src/e2e/download.e2e.test.ts +193 -0
- package/src/e2e/error-handling.e2e.test.ts +292 -0
- package/src/e2e/rsync-options.e2e.test.ts +348 -0
- package/src/e2e/upload.e2e.test.ts +207 -0
- package/src/index.tsx +21 -0
- package/src/test-setup.ts +1 -0
- package/src/types/server.ts +60 -0
- package/src/upload.tsx +404 -0
- package/src/utils/__tests__/sshConfig.test.ts +352 -0
- package/src/utils/__tests__/validation.test.ts +139 -0
- package/src/utils/preferences.ts +24 -0
- package/src/utils/rsync.test.ts +490 -0
- package/src/utils/rsync.ts +517 -0
- package/src/utils/shellEscape.test.ts +98 -0
- package/src/utils/shellEscape.ts +36 -0
- package/src/utils/ssh.test.ts +209 -0
- package/src/utils/ssh.ts +187 -0
- package/src/utils/sshConfig.test.ts +191 -0
- package/src/utils/sshConfig.ts +212 -0
- package/src/utils/validation.test.ts +224 -0
- package/src/utils/validation.ts +115 -0
- package/tsconfig.json +27 -0
- 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
|
+
}
|