From 81b7f2b2352556925625e21c11669f8e6dd57c8e Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Sat, 11 Jan 2025 15:41:03 +0100 Subject: [PATCH] Add dialog to upload all files to GitHub --- src/main.ts | 14 +- src/views/hooks.ts | 10 + .../upload-all-files-dialog/component.tsx | 181 ++++++++++++++++++ src/views/upload-all-files-dialog/view.tsx | 33 ++++ 4 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/views/hooks.ts create mode 100644 src/views/upload-all-files-dialog/component.tsx create mode 100644 src/views/upload-all-files-dialog/view.tsx diff --git a/src/main.ts b/src/main.ts index 19b29e0..06e20e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import MetadataStore from "./metadata-store"; import EventsListener from "./events/listener"; import EventsConsumer from "./events/consumer"; import { type Event } from "./events/types"; +import { UploadDialog } from "./views/upload-all-files-dialog/view"; export default class GitHubSyncPlugin extends Plugin { settings: GitHubSyncSettings; @@ -81,7 +82,7 @@ export default class GitHubSyncPlugin extends Plugin { name: "Upload all files to GitHub", repeatable: false, icon: "arrow-up-from-line", - callback: async () => await this.uploadAllFiles(), + callback: async () => await this.openUploadAllFilesDialog(), }); } @@ -135,8 +136,13 @@ export default class GitHubSyncPlugin extends Plugin { this.updateStatusBarItem(); } - private async uploadAllFiles() { - // TODO + /** + * Opens dialog to upload all file in the tracked folder. + * This doesn't take into account the state of the files and uploads + * ALL files whether they've been modified or not. + */ + private async openUploadAllFilesDialog() { + new UploadDialog(this).open(); this.updateStatusBarItem(); } @@ -265,7 +271,7 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadAllFilesRibbonIcon = this.addRibbonIcon( "arrow-up-from-line", "Upload all files to GitHub", - async () => await this.uploadAllFiles(), + async () => await this.openUploadAllFilesDialog(), ); } diff --git a/src/views/hooks.ts b/src/views/hooks.ts new file mode 100644 index 0000000..4b81d98 --- /dev/null +++ b/src/views/hooks.ts @@ -0,0 +1,10 @@ +import { createContext, useContext } from "react"; +import GitHubSyncPlugin from "src/main"; + +export const PluginContext = createContext( + undefined, +); + +export const usePlugin = (): GitHubSyncPlugin | undefined => { + return useContext(PluginContext); +}; diff --git a/src/views/upload-all-files-dialog/component.tsx b/src/views/upload-all-files-dialog/component.tsx new file mode 100644 index 0000000..2bd3f6e --- /dev/null +++ b/src/views/upload-all-files-dialog/component.tsx @@ -0,0 +1,181 @@ +import { usePlugin } from "../hooks"; +import { useState, useMemo, useEffect } from "react"; + +const UploadDialogQuestion = ({ + fileCount, + onCancel, + startUpload: onUpload, +}: { + fileCount: number; + onCancel: () => void; + startUpload: () => Promise; +}) => { + return ( +
+

Upload files to GitHub

+

+ Do you want to upload{" "} + {fileCount} files? This + might take a while. +

+
+ + +
+
+ ); +}; + +const UploadDialogUploading = ({ + completedCount, + fileCount, + onCancel, +}: { + completedCount: number; + fileCount: number; + onCancel: () => void; +}) => { + const progress = (completedCount * 100) / fileCount; + + return ( +
+

+ {progress === 100 ? "All files uploaded" : "Uploading…"} +

+
+
+
+

+ Uploaded {completedCount} file{completedCount === 1 ? "" : "s"} of{" "} + {fileCount} +

+
+ {/* + We use onCancel for both cases as it won't have any effect other + than closing the modal if upload is done + */} + +
+
+ ); +}; + +const UploadDialogContent = ({ onCancel }: { onCancel: () => void }) => { + const plugin = usePlugin(); + if (!plugin) { + // Unlikely to happen, makes TS happy though + throw new Error("Plugin is not initialized"); + } + const [uploading, setUploading] = useState(false); + const [completedCount, setCompletedCount] = useState(0); + + const files = useMemo( + // This is not the most efficient way to get all files in the + // folder to sync but it's quick to code. + // I'll enhance it in the future. + () => + plugin?.app.vault + .getFiles() + .filter((file) => + file.path.startsWith(plugin.settings.localContentDir), + ), + [], + ); + + const abortController = useMemo(() => new AbortController(), []); + + // Stop the upload when the component is unmounted, otherwise + // we keep uploading even when the modal is closed. + useEffect(() => { + return () => { + abortController.abort(); + }; + }, []); + + const startUploading = async () => { + setUploading(true); + // We upload files sequentially to avoid conflicts. + // GitHub rejects commits if they're made in fast succession, thus + // forcing us to retry the failed upload. + // So parallelization is not an option. + for (const file of files) { + console.log(`Uploading ${file.path}`); + if (abortController.signal.aborted) { + return; + } + await plugin.client.uploadFile( + plugin.settings.githubOwner, + plugin.settings.githubRepo, + plugin.settings.githubBranch, + file.path, + ); + // Reset dirty state + plugin.metadataStore.data[file.path].dirty = false; + + // Gets the new SHA of the file + const sha = await plugin.client.getFileSha( + plugin.settings.githubOwner, + plugin.settings.githubRepo, + plugin.settings.githubBranch, + file.path, + ); + plugin.metadataStore.data[file.path].sha = sha; + await plugin.metadataStore.save(); + + if (abortController.signal.aborted) { + // Check abort state only after the uploaded file metadata + // is updated, otherwise we risk having outdated SHAs + return; + } + setCompletedCount((count) => count + 1); + console.log(`Uploaded ${file.path}`); + } + }; + + return ( +
+ {!uploading && ( + + )} + {uploading && ( + { + abortController.abort(); + onCancel(); + }} + /> + )} +
+ ); +}; + +export default UploadDialogContent; diff --git a/src/views/upload-all-files-dialog/view.tsx b/src/views/upload-all-files-dialog/view.tsx new file mode 100644 index 0000000..d563f2e --- /dev/null +++ b/src/views/upload-all-files-dialog/view.tsx @@ -0,0 +1,33 @@ +import { Modal } from "obsidian"; +import { Root, createRoot } from "react-dom/client"; +import GitHubSyncPlugin from "src/main"; +import { PluginContext } from "../hooks"; +import UploadDialogContent from "./component"; + +/** + * Dialog shown when user tries to upload all local files to GitHub. + */ +export class UploadDialog extends Modal { + root: Root | null = null; + + constructor(private plugin: GitHubSyncPlugin) { + super(plugin.app); + } + + onOpen() { + this.root = createRoot(this.modalEl); + this.root.render( + + this.close()} /> + , + ); + } + + onClose() { + const { contentEl } = this; + // It's important to unmount the component so upload stops + // in case it's running + this.root?.unmount(); + contentEl.empty(); + } +}