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/src/upload.tsx
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import {
|
|
2
|
+
List,
|
|
3
|
+
ActionPanel,
|
|
4
|
+
Action,
|
|
5
|
+
Form,
|
|
6
|
+
showToast,
|
|
7
|
+
Toast,
|
|
8
|
+
getSelectedFinderItems,
|
|
9
|
+
popToRoot,
|
|
10
|
+
} from "@raycast/api";
|
|
11
|
+
import React, { useState, useEffect } from "react";
|
|
12
|
+
import { parseSSHConfig } from "./utils/sshConfig";
|
|
13
|
+
import { executeRsync } from "./utils/rsync";
|
|
14
|
+
import {
|
|
15
|
+
validateLocalPath,
|
|
16
|
+
validateRemotePath,
|
|
17
|
+
validateHostConfig,
|
|
18
|
+
} from "./utils/validation";
|
|
19
|
+
import {
|
|
20
|
+
SSHHostConfig,
|
|
21
|
+
TransferDirection,
|
|
22
|
+
TransferOptions,
|
|
23
|
+
RsyncOptions,
|
|
24
|
+
} from "./types/server";
|
|
25
|
+
import { getRsyncPreferences } from "./utils/preferences";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Main upload command component
|
|
29
|
+
* Displays list of SSH hosts from config file
|
|
30
|
+
*/
|
|
31
|
+
export default function Command() {
|
|
32
|
+
const [hosts, setHosts] = useState<SSHHostConfig[]>([]);
|
|
33
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
34
|
+
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
loadHosts();
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
async function loadHosts() {
|
|
41
|
+
try {
|
|
42
|
+
const parsedHosts = parseSSHConfig();
|
|
43
|
+
|
|
44
|
+
if (parsedHosts.length === 0) {
|
|
45
|
+
const errorMsg = "No host entries found in SSH config file";
|
|
46
|
+
setError(errorMsg);
|
|
47
|
+
console.warn("SSH config parsed but no hosts found");
|
|
48
|
+
} else {
|
|
49
|
+
setHosts(parsedHosts);
|
|
50
|
+
console.log(`Loaded ${parsedHosts.length} SSH host(s)`);
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
const errorMessage =
|
|
54
|
+
err instanceof Error ? err.message : "Failed to parse SSH config";
|
|
55
|
+
console.error("Error loading SSH hosts:", err);
|
|
56
|
+
setError(errorMessage);
|
|
57
|
+
await showToast({
|
|
58
|
+
style: Toast.Style.Failure,
|
|
59
|
+
title: "Error Loading SSH Config",
|
|
60
|
+
message: errorMessage,
|
|
61
|
+
});
|
|
62
|
+
} finally {
|
|
63
|
+
setIsLoading(false);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (error) {
|
|
68
|
+
return (
|
|
69
|
+
<List>
|
|
70
|
+
<List.EmptyView title="Error Loading SSH Config" description={error} />
|
|
71
|
+
</List>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<List isLoading={isLoading} searchBarPlaceholder="Search hosts...">
|
|
77
|
+
{hosts.map((host: SSHHostConfig) => (
|
|
78
|
+
<List.Item
|
|
79
|
+
key={host.host}
|
|
80
|
+
title={host.host}
|
|
81
|
+
subtitle={host.hostName}
|
|
82
|
+
accessories={[
|
|
83
|
+
{ text: host.user ? `User: ${host.user}` : "" },
|
|
84
|
+
{ text: host.port ? `Port: ${host.port}` : "" },
|
|
85
|
+
]}
|
|
86
|
+
actions={
|
|
87
|
+
<ActionPanel>
|
|
88
|
+
<Action.Push
|
|
89
|
+
title="Select Files to Upload"
|
|
90
|
+
target={<FileSelectionView hostConfig={host} />}
|
|
91
|
+
/>
|
|
92
|
+
</ActionPanel>
|
|
93
|
+
}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</List>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* File selection view
|
|
102
|
+
* Allows user to select local files to upload
|
|
103
|
+
*/
|
|
104
|
+
function FileSelectionView({ hostConfig }: { hostConfig: SSHHostConfig }) {
|
|
105
|
+
const [selectedPath, setSelectedPath] = useState<string>("");
|
|
106
|
+
const [isSelecting, setIsSelecting] = useState(false);
|
|
107
|
+
|
|
108
|
+
async function selectFiles() {
|
|
109
|
+
setIsSelecting(true);
|
|
110
|
+
try {
|
|
111
|
+
// Try to get selected Finder items first
|
|
112
|
+
const finderItems = await getSelectedFinderItems();
|
|
113
|
+
|
|
114
|
+
if (finderItems.length > 0) {
|
|
115
|
+
// Use the first selected item
|
|
116
|
+
setSelectedPath(finderItems[0].path);
|
|
117
|
+
console.log("Selected file from Finder:", finderItems[0].path);
|
|
118
|
+
} else {
|
|
119
|
+
// No items selected, fall back to manual entry
|
|
120
|
+
setSelectedPath(""); // Reset and show form
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Check if error is about Finder not being frontmost
|
|
124
|
+
const errorMessage =
|
|
125
|
+
err instanceof Error ? err.message : "Failed to select files";
|
|
126
|
+
|
|
127
|
+
if (errorMessage.includes("Finder isn't the frontmost application")) {
|
|
128
|
+
// Silently fall back to manual entry - this is expected behavior
|
|
129
|
+
// when user opens the extension directly without selecting files in Finder first
|
|
130
|
+
console.log("Finder not frontmost, falling back to manual path entry");
|
|
131
|
+
setSelectedPath(""); // Show form for manual entry
|
|
132
|
+
} else {
|
|
133
|
+
// For other errors, log and show notification
|
|
134
|
+
console.error("Error selecting files:", err);
|
|
135
|
+
await showToast({
|
|
136
|
+
style: Toast.Style.Failure,
|
|
137
|
+
title: "File Selection Error",
|
|
138
|
+
message: errorMessage,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
} finally {
|
|
142
|
+
setIsSelecting(false);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
selectFiles();
|
|
148
|
+
}, []);
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Form
|
|
152
|
+
isLoading={isSelecting}
|
|
153
|
+
actions={
|
|
154
|
+
<ActionPanel>
|
|
155
|
+
<Action.Push
|
|
156
|
+
title="Continue"
|
|
157
|
+
target={
|
|
158
|
+
<RemotePathForm
|
|
159
|
+
hostConfig={hostConfig}
|
|
160
|
+
localPath={selectedPath}
|
|
161
|
+
/>
|
|
162
|
+
}
|
|
163
|
+
/>
|
|
164
|
+
</ActionPanel>
|
|
165
|
+
}
|
|
166
|
+
>
|
|
167
|
+
<Form.TextField
|
|
168
|
+
id="localPath"
|
|
169
|
+
title="Local Path"
|
|
170
|
+
placeholder="/path/to/local/file"
|
|
171
|
+
value={selectedPath}
|
|
172
|
+
onChange={setSelectedPath}
|
|
173
|
+
info="Select files or folders in Finder before opening this extension, or manually enter the path to the file or directory you want to upload"
|
|
174
|
+
/>
|
|
175
|
+
<Form.Description
|
|
176
|
+
title="Host Details"
|
|
177
|
+
text={`Uploading to: ${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
|
|
178
|
+
/>
|
|
179
|
+
{hostConfig.user && (
|
|
180
|
+
<Form.Description title="User" text={hostConfig.user} />
|
|
181
|
+
)}
|
|
182
|
+
{hostConfig.port && (
|
|
183
|
+
<Form.Description title="Port" text={hostConfig.port.toString()} />
|
|
184
|
+
)}
|
|
185
|
+
{hostConfig.identityFile && (
|
|
186
|
+
<Form.Description
|
|
187
|
+
title="Identity File"
|
|
188
|
+
text={hostConfig.identityFile}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
</Form>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Remote path input form
|
|
197
|
+
* Allows user to specify destination path on remote server
|
|
198
|
+
*/
|
|
199
|
+
function RemotePathForm({
|
|
200
|
+
hostConfig,
|
|
201
|
+
localPath,
|
|
202
|
+
}: {
|
|
203
|
+
hostConfig: SSHHostConfig;
|
|
204
|
+
localPath: string;
|
|
205
|
+
}) {
|
|
206
|
+
const [remotePath, setRemotePath] = useState<string>("");
|
|
207
|
+
const [remotePathError, setRemotePathError] = useState<string | undefined>();
|
|
208
|
+
|
|
209
|
+
// Initialize rsync options with global preferences
|
|
210
|
+
const defaultRsyncOptions = getRsyncPreferences();
|
|
211
|
+
const [humanReadable, setHumanReadable] = useState<boolean>(
|
|
212
|
+
defaultRsyncOptions.humanReadable ?? false,
|
|
213
|
+
);
|
|
214
|
+
const [progress, setProgress] = useState<boolean>(
|
|
215
|
+
defaultRsyncOptions.progress ?? false,
|
|
216
|
+
);
|
|
217
|
+
const [deleteExtra, setDeleteExtra] = useState<boolean>(
|
|
218
|
+
defaultRsyncOptions.delete ?? false,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
async function handleSubmit(values: {
|
|
222
|
+
remotePath: string;
|
|
223
|
+
humanReadable: boolean;
|
|
224
|
+
progress: boolean;
|
|
225
|
+
deleteExtra: boolean;
|
|
226
|
+
}) {
|
|
227
|
+
const remotePathValue = values.remotePath.trim();
|
|
228
|
+
|
|
229
|
+
// Validate local path
|
|
230
|
+
const localValidation = validateLocalPath(localPath);
|
|
231
|
+
if (!localValidation.valid) {
|
|
232
|
+
console.error("Local path validation failed:", localValidation.error);
|
|
233
|
+
await showToast({
|
|
234
|
+
style: Toast.Style.Failure,
|
|
235
|
+
title: "Invalid Local Path",
|
|
236
|
+
message:
|
|
237
|
+
localValidation.error ||
|
|
238
|
+
"The specified local file or directory does not exist",
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Validate remote path
|
|
244
|
+
const remoteValidation = validateRemotePath(remotePathValue);
|
|
245
|
+
if (!remoteValidation.valid) {
|
|
246
|
+
console.error("Remote path validation failed:", remoteValidation.error);
|
|
247
|
+
setRemotePathError(remoteValidation.error);
|
|
248
|
+
await showToast({
|
|
249
|
+
style: Toast.Style.Failure,
|
|
250
|
+
title: "Invalid Remote Path",
|
|
251
|
+
message: remoteValidation.error || "The remote path format is invalid",
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Validate host config
|
|
257
|
+
const hostValidation = validateHostConfig(hostConfig);
|
|
258
|
+
if (!hostValidation.valid) {
|
|
259
|
+
console.error("Host config validation failed:", hostValidation.error);
|
|
260
|
+
await showToast({
|
|
261
|
+
style: Toast.Style.Failure,
|
|
262
|
+
title: "Invalid Host Configuration",
|
|
263
|
+
message:
|
|
264
|
+
hostValidation.error ||
|
|
265
|
+
"The host configuration is incomplete or invalid",
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Execute transfer using form values
|
|
271
|
+
await executeTransfer(hostConfig, localPath, remotePathValue, {
|
|
272
|
+
humanReadable: values.humanReadable,
|
|
273
|
+
progress: values.progress,
|
|
274
|
+
delete: values.deleteExtra,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function executeTransfer(
|
|
279
|
+
hostConfig: SSHHostConfig,
|
|
280
|
+
localPath: string,
|
|
281
|
+
remotePath: string,
|
|
282
|
+
rsyncOptions: RsyncOptions,
|
|
283
|
+
) {
|
|
284
|
+
// Show initial progress toast
|
|
285
|
+
await showToast({
|
|
286
|
+
style: Toast.Style.Animated,
|
|
287
|
+
title: "Transferring files...",
|
|
288
|
+
message: `Uploading to ${hostConfig.host}`,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
console.log("Starting upload:", {
|
|
292
|
+
host: hostConfig.host,
|
|
293
|
+
localPath,
|
|
294
|
+
remotePath,
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const options: TransferOptions = {
|
|
299
|
+
hostConfig,
|
|
300
|
+
localPath,
|
|
301
|
+
remotePath,
|
|
302
|
+
direction: TransferDirection.UPLOAD,
|
|
303
|
+
rsyncOptions,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
// Progress callback to update toast in real-time
|
|
307
|
+
const progressCallback = async (progressMessage: string) => {
|
|
308
|
+
await showToast({
|
|
309
|
+
style: Toast.Style.Animated,
|
|
310
|
+
title: "Transferring files...",
|
|
311
|
+
message: progressMessage,
|
|
312
|
+
});
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
const result = await executeRsync(options, progressCallback);
|
|
316
|
+
|
|
317
|
+
if (result.success) {
|
|
318
|
+
console.log("Upload completed successfully");
|
|
319
|
+
// Show formatted rsync output message (includes file sizes and progress if flags enabled)
|
|
320
|
+
await showToast({
|
|
321
|
+
style: Toast.Style.Success,
|
|
322
|
+
title: "Upload Successful",
|
|
323
|
+
message: result.message,
|
|
324
|
+
});
|
|
325
|
+
// Log full output for debugging
|
|
326
|
+
if (result.stdout) {
|
|
327
|
+
console.log("Rsync output:", result.stdout);
|
|
328
|
+
}
|
|
329
|
+
// Close the extension after successful upload
|
|
330
|
+
await popToRoot();
|
|
331
|
+
} else {
|
|
332
|
+
console.error("Upload failed:", result.message);
|
|
333
|
+
await showToast({
|
|
334
|
+
style: Toast.Style.Failure,
|
|
335
|
+
title: "Upload Failed",
|
|
336
|
+
message: result.message,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
} catch (err) {
|
|
340
|
+
const errorMessage =
|
|
341
|
+
err instanceof Error ? err.message : "Unknown error occurred";
|
|
342
|
+
console.error("Upload error:", err);
|
|
343
|
+
await showToast({
|
|
344
|
+
style: Toast.Style.Failure,
|
|
345
|
+
title: "Upload Failed",
|
|
346
|
+
message: errorMessage,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return (
|
|
352
|
+
<Form
|
|
353
|
+
actions={
|
|
354
|
+
<ActionPanel>
|
|
355
|
+
<Action.SubmitForm title="Upload" onSubmit={handleSubmit} />
|
|
356
|
+
</ActionPanel>
|
|
357
|
+
}
|
|
358
|
+
>
|
|
359
|
+
<Form.TextField
|
|
360
|
+
id="remotePath"
|
|
361
|
+
title="Remote Path"
|
|
362
|
+
placeholder="/path/to/remote/destination"
|
|
363
|
+
value={remotePath}
|
|
364
|
+
onChange={(value: string) => {
|
|
365
|
+
setRemotePath(value);
|
|
366
|
+
setRemotePathError(undefined);
|
|
367
|
+
}}
|
|
368
|
+
error={remotePathError}
|
|
369
|
+
info="Enter the destination path on the remote server"
|
|
370
|
+
/>
|
|
371
|
+
<Form.Description title="Local Path" text={localPath} />
|
|
372
|
+
<Form.Description
|
|
373
|
+
title="Host"
|
|
374
|
+
text={`${hostConfig.host}${hostConfig.hostName ? ` (${hostConfig.hostName})` : ""}`}
|
|
375
|
+
/>
|
|
376
|
+
<Form.Separator />
|
|
377
|
+
<Form.Description
|
|
378
|
+
title="Rsync Options"
|
|
379
|
+
text="Configure options for this transfer"
|
|
380
|
+
/>
|
|
381
|
+
<Form.Checkbox
|
|
382
|
+
id="humanReadable"
|
|
383
|
+
label="Human-readable file sizes (-h)"
|
|
384
|
+
value={humanReadable}
|
|
385
|
+
onChange={setHumanReadable}
|
|
386
|
+
info="Display file sizes in human-readable format (e.g., 1.5M, 500K)"
|
|
387
|
+
/>
|
|
388
|
+
<Form.Checkbox
|
|
389
|
+
id="progress"
|
|
390
|
+
label="Show progress (-P)"
|
|
391
|
+
value={progress}
|
|
392
|
+
onChange={setProgress}
|
|
393
|
+
info="Display progress information and support partial transfers"
|
|
394
|
+
/>
|
|
395
|
+
<Form.Checkbox
|
|
396
|
+
id="deleteExtra"
|
|
397
|
+
label="Delete extraneous files (--delete)"
|
|
398
|
+
value={deleteExtra}
|
|
399
|
+
onChange={setDeleteExtra}
|
|
400
|
+
info="Delete files in destination that don't exist in source (use with caution)"
|
|
401
|
+
/>
|
|
402
|
+
</Form>
|
|
403
|
+
);
|
|
404
|
+
}
|