From fc890b5d951ada4dc1ac193dd558592ce19f0abd Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Sat, 1 Mar 2025 16:43:25 +0100 Subject: [PATCH] Purge onboarding flow The onboarding flow and UI was a bit clunky and made it annoying to handle failures, backtracking, errors made by the user, etc. I decided to remove it completely and just show a notice to the user if the plugin is not correctly configured, forcing them to take action. Documentation is going to be key in teaching the user how to configure the plugin properly. --- src/main.ts | 47 ++- src/settings/settings.ts | 4 +- src/sync-manager.ts | 34 +- src/views/onboarding/component.tsx | 556 ----------------------------- src/views/onboarding/view.tsx | 42 --- 5 files changed, 54 insertions(+), 629 deletions(-) delete mode 100644 src/views/onboarding/component.tsx delete mode 100644 src/views/onboarding/view.tsx diff --git a/src/main.ts b/src/main.ts index 6a6aca4..a6a750e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,11 +4,11 @@ import { Platform, WorkspaceLeaf, normalizePath, + Notice, } from "obsidian"; import { GitHubSyncSettings, DEFAULT_SETTINGS } from "./settings/settings"; import GitHubSyncSettingsTab from "./settings/tab"; import SyncManager, { ConflictFile, ConflictResolution } from "./sync-manager"; -import { OnboardingDialog } from "./views/onboarding/view"; import Logger from "./logger"; import { ConflictsResolutionView, @@ -41,14 +41,13 @@ export default class GitHubSyncPlugin extends Plugin { private conflicts: ConflictFile[] = []; async onUserEnable() { - if (Platform.isMobile) { - // TODO: Implement onboarding for mobile - this.settings.firstStart = false; - this.saveSettings(); - return; - } - if (this.settings.firstStart) { - new OnboardingDialog(this).open(); + if ( + this.settings.githubToken === "" || + this.settings.githubOwner === "" || + this.settings.githubRepo === "" || + this.settings.githubBranch === "" + ) { + new Notice("Go to settings to configure syncing"); } } @@ -129,13 +128,30 @@ export default class GitHubSyncPlugin extends Plugin { name: "Sync with GitHub", repeatable: false, icon: "refresh-cw", - callback: async () => { - await this.syncManager.sync(); - this.updateStatusBarItem(); - }, + callback: this.sync.bind(this), }); } + async sync() { + if ( + this.settings.githubToken === "" || + this.settings.githubOwner === "" || + this.settings.githubRepo === "" || + this.settings.githubBranch === "" + ) { + new Notice("Sync plugin not configured"); + return; + } + if (this.settings.firstSync) { + await this.syncManager.firstSync(); + this.settings.firstSync = false; + this.saveSettings(); + } else { + await this.syncManager.sync(); + } + this.updateStatusBarItem(); + } + async onunload() { this.stopSyncInterval(); } @@ -198,10 +214,7 @@ export default class GitHubSyncPlugin extends Plugin { this.uploadModifiedFilesRibbonIcon = this.addRibbonIcon( "refresh-cw", "Sync with GitHub", - async () => { - await this.syncManager.sync(); - this.updateStatusBarItem(); - }, + this.sync.bind(this), ); } diff --git a/src/settings/settings.ts b/src/settings/settings.ts index bb7ae29..97dba70 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -1,5 +1,5 @@ export interface GitHubSyncSettings { - firstStart: boolean; + firstSync: boolean; githubToken: string; githubOwner: string; githubRepo: string; @@ -15,7 +15,7 @@ export interface GitHubSyncSettings { } export const DEFAULT_SETTINGS: GitHubSyncSettings = { - firstStart: true, + firstSync: true, githubToken: "", githubOwner: "", githubRepo: "", diff --git a/src/sync-manager.ts b/src/sync-manager.ts index 8697fc9..0efaef0 100644 --- a/src/sync-manager.ts +++ b/src/sync-manager.ts @@ -87,17 +87,24 @@ export default class SyncManager { return; } + const notice = new Notice("Syncing..."); this.syncing = true; try { - this.firstSyncImpl(); - } finally { - this.syncing = false; + await this.firstSyncImpl(); + // Shown only if sync doesn't fail + new Notice("Sync successful", 5000); + } catch (err) { + // Show the error to the user, it's not automatically dismissed to make sure + // the user sees it. + new Notice(`Error syncing. ${err}`); } + this.syncing = false; + notice.hide(); } private async firstSyncImpl() { await this.logger.info("Starting first sync"); - let repositoryIsBare = false; + let repositoryIsEmpty = false; let res: RepoContent; let files: { [key: string]: GetTreeResponseItem; @@ -108,16 +115,20 @@ export default class SyncManager { files = res.files; treeSha = res.sha; } catch (err) { - if (err.status !== 409) { + // 409 is returned in case the remote repo has been just created + // and contains no files. + // 404 instead is returned in case there are no files. + // Either way we can handle both by commiting a new empty manifest. + if (err.status !== 409 && err.status !== 404) { this.syncing = false; throw err; } // The repository is bare, meaning it has no tree, no commits and no branches - repositoryIsBare = true; + repositoryIsEmpty = true; } - if (repositoryIsBare) { - await this.logger.info("Remote repository is bare"); + if (repositoryIsEmpty) { + await this.logger.info("Remote repository is empty"); // Since the repository is completely empty we need to create a first commit. // We can't create that by going throught the normal sync process since the // API doesn't let us create a new tree when the repo is empty. @@ -126,7 +137,7 @@ export default class SyncManager { await this.client.createFile( `${this.vault.configDir}/${MANIFEST_FILE_NAME}`, "", - "Initial commit", + "First sync", ); // Now get the repo content again cause we know for sure it will return a // valid sha that we can use to create the first sync commit. @@ -135,14 +146,13 @@ export default class SyncManager { treeSha = res.sha; } - const remoteRepoIsEmpty = Object.keys(files).length === 0; const vaultIsEmpty = await this.vaultIsEmpty(); - if (!remoteRepoIsEmpty && !vaultIsEmpty) { + if (!repositoryIsEmpty && !vaultIsEmpty) { // Both have files, we can't sync, show error await this.logger.error("Both remote and local have files, can't sync"); throw new Error("Both remote and local have files, can't sync"); - } else if (remoteRepoIsEmpty || repositoryIsBare) { + } else if (repositoryIsEmpty) { // Remote has no files and no manifest, let's just upload whatever we have locally. // This is fine even if the vault is empty. // The most important thing at this point is that the remote manifest is created. diff --git a/src/views/onboarding/component.tsx b/src/views/onboarding/component.tsx deleted file mode 100644 index 65dd91e..0000000 --- a/src/views/onboarding/component.tsx +++ /dev/null @@ -1,556 +0,0 @@ -import { useState, useEffect } from "react"; -import SyncManager from "src/sync-manager"; -import { usePlugin } from "src/views/hooks"; -import { DEFAULT_SETTINGS, GitHubSyncSettings } from "src/settings/settings"; - -const STEPS = ["welcome", "repo", "token", "sync", "first_sync"] as const; - -type Step = (typeof STEPS)[number]; - -type StepData = { - repo: { owner: string; repo: string; branch: string }; - token: { token: string }; - sync: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }; -}; - -const DEFAULT_STEP_DATA: StepData = { - repo: { owner: "", repo: "", branch: "" }, - token: { token: "" }, - sync: { - mode: "manual", - syncOnStart: false, - syncConfigDir: true, - onConflict: "overwrite", - }, -}; - -const WelcomeStepComponent = () => { - return ( -
-

First setup

-

- This plugin is still in beta so not all features are available yet. If - you find any issues report that. -

-

- You'll be guided through some steps to get you started. -

-
- ); -}; - -const RepoStepComponent = ({ - values, - onChange, -}: { - values: { owner: string; repo: string; branch: string }; - onChange: (values: { owner: string; repo: string; branch: string }) => void; -}) => { - return ( -
-

- First we need the repository owner, name and branch. -

-

- You must use a repository that you have read and write access to. If - your vault already has content I suggest creating a new private - repository to sync. -

-
- onChange({ ...values, owner: e.target.value })} - /> - onChange({ ...values, repo: e.target.value })} - /> - onChange({ ...values, branch: e.target.value })} - /> -
-
- ); -}; - -const TokenStepComponent = ({ - values, - onChange, -}: { - values: { token: string }; - onChange: (values: { token: string }) => void; -}) => { - return ( -
-

- Now we need a GitHub Personal Access Token. -

-

- The token is used to get, create and update the files between your vault - and your GitHub repository. Click{" "} - here to - create a new token. For more information about the tokens see{" "} - - the official GitHub documentation. - -

-

- The token must have the Contents Read and Write permissions for the same - repository you set in the previous step. -

- onChange({ token: e.target.value })} - /> -
- ); -}; - -const SyncSettingsStepComponent = ({ - values, - onChange, -}: { - values: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }; - onChange: (values: { - mode: "manual" | "interval"; - syncOnStart: boolean; - syncConfigDir: boolean; - onConflict: "ignore" | "ask" | "overwrite"; - }) => void; -}) => { - return ( -
-

- You can sync your vault manually or every minute. You can change the - interval at a later time. -

- -

Optionally you can sync every time you open Obsidian.

-
- onChange({ ...values, syncOnStart: !values.syncOnStart }) - } - > - -
-

- If you want different configs for different vaults you can disable - config syncing. -

-
- onChange({ ...values, syncConfigDir: !values.syncConfigDir }) - } - > - -
-

In case of conflicts you can choose how to handle them.

-

- This is not yet implemented so remote files will always overwrite local - files in case of conflicts. -

- -
- ); -}; - -const FirstSyncStepComponent = ({ - stepData, - onClose, -}: { - stepData: StepData; - onClose: () => Promise; -}) => { - const [syncing, setSyncing] = useState(true); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const plugin = usePlugin(); - if (!plugin) { - // Unlikely to happen, makes TS happy though - throw new Error("Plugin is not initialized"); - } - useEffect(() => { - (async () => { - const settings: GitHubSyncSettings = { - ...DEFAULT_SETTINGS, - githubToken: stepData.token.token, - githubOwner: stepData.repo.owner, - githubRepo: stepData.repo.repo, - githubBranch: stepData.repo.branch, - syncStrategy: stepData.sync.mode, - syncOnStartup: stepData.sync.syncOnStart, - syncConfigDir: stepData.sync.syncConfigDir, - conflictHandling: stepData.sync.onConflict, - }; - // We don't want to save the settings yet, so we create a new sync manager - // used only for the first sync. - // After that's sucessful we'll save the settings. - const syncManager = new SyncManager( - plugin.app.vault, - settings, - // There can't be any conflicts at this point so we don't need to handle them - async (_) => { - return []; - }, - plugin.logger, - ); - await syncManager.loadMetadata(); - // When loading the plugin we also load the metadata, though since by default - // configs sync is enabled we need to remove them if necessary. Otherwise they - // would be synced even if the user doesn't want them to be. - // We also add them if they want to sync config dirs just for completeness, though - // they should already be there. - if (stepData.sync.syncConfigDir) { - await syncManager.addConfigDirToMetadata(); - } else { - await syncManager.removeConfigDirFromMetadata(); - } - try { - await syncManager.firstSync(); - } catch (e) { - setError(e.message); - setSuccess(false); - setSyncing(false); - return; - } - plugin.settings = settings; - await plugin.saveSettings(); - setSyncing(false); - setSuccess(true); - })(); - }, [stepData]); - - const loadingBarStyle = () => { - if (error) { - return { width: 0 }; - } else if (success) { - return { width: "100%" }; - } else { - return {}; - } - }; - - return ( -
-

- Now we can try and sync your vault with your repository. This might take - a few minutes depending on the number of files. -

-
-
- {syncing ? "Syncing..." : null} - {error ? "Syncing failed" : null} - {success ? "Syncing complete" : null} -
-
-
-
-
-
-
- {error ?
{error}
: null} - {success ?
Everything is set up and ready to go.
: null} -
-
- {success ? ( -
- -
- ) : null} -
- ); -}; - -const OnBoardingComponent = ({ onClose }: { onClose: () => Promise }) => { - // It starts as true because the first step is just a welcome message - // so the button is already enabled. - const [isValid, setIsValid] = useState(true); - const [step, setStep] = useState("welcome"); - const [stepData, setStepData] = useState(DEFAULT_STEP_DATA); - - const updateStepData = (step: Step, data: StepData[keyof StepData]) => { - setStepData((stepData) => { - return { - ...stepData, - [step]: data, - }; - }); - }; - - const isStepValid = (step: Step) => { - switch (step) { - // Only these steps have required data, the others don't - // so the step is automatically valid - case "repo": - const { owner, repo, branch } = stepData.repo ?? {}; - return Boolean(owner && repo && branch); - case "token": - return Boolean(stepData.token?.token); - case "sync": - default: - return true; - } - }; - - useEffect(() => { - setIsValid(isStepValid(step)); - }, [step, stepData]); - - const previousStep = () => { - setStep((currentStep) => { - const currentIndex = STEPS.indexOf(currentStep); - return STEPS[currentIndex - 1] ?? STEPS[0]; - }); - }; - - const nextStep = () => { - setStep((currentStep) => { - const currentIndex = STEPS.indexOf(currentStep); - return STEPS[currentIndex + 1] ?? STEPS[STEPS.length - 1]; - }); - }; - - const currentStepComponent = () => { - switch (step) { - case "welcome": - return ; - case "repo": - return ( - updateStepData("repo", data)} - /> - ); - case "token": - return ( - updateStepData("token", data)} - /> - ); - case "sync": - return ( - updateStepData("sync", data)} - /> - ); - case "first_sync": - return ; - } - }; - return ( -
-
- - {currentStepComponent()} - -
-
- ); -}; - -export default OnBoardingComponent; diff --git a/src/views/onboarding/view.tsx b/src/views/onboarding/view.tsx deleted file mode 100644 index 0c47580..0000000 --- a/src/views/onboarding/view.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Modal } from "obsidian"; -import { Root, createRoot } from "react-dom/client"; -import GitHubSyncPlugin from "src/main"; -import { PluginContext } from "src/views/hooks"; -import OnboardingDialogComponent from "./component"; - -export class OnboardingDialog extends Modal { - root: Root | null = null; - - constructor(private plugin: GitHubSyncPlugin) { - super(plugin.app); - } - - onOpen() { - this.root = createRoot(this.modalEl); - // Make the dialog look like the settings modal - this.modalEl.addClass("mod-settings"); - this.modalEl.addClass("mod-sidebar-layout"); - - this.root.render( - - { - this.plugin.settings.firstStart = false; - // Save the settings only after a successful first sync - await this.plugin.saveSettings(); - // Reload metadata after sync to be sure that the main sync manager - // uses the latest version - await this.plugin.syncManager.loadMetadata(); - this.close(); - }} - /> - , - ); - } - - async onClose() { - const { contentEl } = this; - this.root?.unmount(); - contentEl.empty(); - } -}