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/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
+ }