Skip to content

Commit

Permalink
Web: Add support for auto-reloading dev plugins on change (#11545)
Browse files Browse the repository at this point in the history
  • Loading branch information
personalizedrefrigerator authored Jan 9, 2025
1 parent a81af07 commit 98fce34
Show file tree
Hide file tree
Showing 20 changed files with 249 additions and 64 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ packages/app-mobile/components/plugins/dialogs/hooks/useViewInfos.js
packages/app-mobile/components/plugins/dialogs/hooks/useWebViewSetup.js
packages/app-mobile/components/plugins/types.js
packages/app-mobile/components/plugins/utils/createOnLogHandler.js
packages/app-mobile/components/plugins/utils/useOnDevPluginsUpdated.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/JoplinCloudConfig.js
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,7 @@ packages/app-mobile/components/plugins/dialogs/hooks/useViewInfos.js
packages/app-mobile/components/plugins/dialogs/hooks/useWebViewSetup.js
packages/app-mobile/components/plugins/types.js
packages/app-mobile/components/plugins/utils/createOnLogHandler.js
packages/app-mobile/components/plugins/utils/useOnDevPluginsUpdated.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/FileSystemPathSelector.js
packages/app-mobile/components/screens/ConfigScreen/JoplinCloudConfig.js
Expand Down
20 changes: 18 additions & 2 deletions packages/app-mobile/components/plugins/PluginRunnerWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { AppState } from '../../utils/types';
import usePrevious from '@joplin/lib/hooks/usePrevious';
import PlatformImplementation from '../../services/plugins/PlatformImplementation';
import AccessibleView from '../accessibility/AccessibleView';
import useOnDevPluginsUpdated from './utils/useOnDevPluginsUpdated';

const logger = Logger.create('PluginRunnerWebView');

Expand All @@ -29,20 +30,33 @@ const usePlugins = (
pluginRunner: PluginRunner,
webviewLoaded: boolean,
pluginSettings: PluginSettings,
pluginSupportEnabled: boolean,
devPluginPath: string,
) => {
const store = useStore<AppState>();
const lastPluginRunner = usePrevious(pluginRunner);
const [reloadCounter, setReloadCounter] = useState(0);

// Only set reloadAll to true here -- this ensures that all plugins are reloaded,
// even if loadPlugins is cancelled and re-run.
const reloadAllRef = useRef(false);
reloadAllRef.current ||= pluginRunner !== lastPluginRunner;

useOnDevPluginsUpdated(async (pluginId: string) => {
logger.info(`Dev plugin ${pluginId} updated. Reloading...`);
await PluginService.instance().unloadPlugin(pluginId);
setReloadCounter(counter => counter + 1);
}, devPluginPath, pluginSupportEnabled);

useAsyncEffect(async (event) => {
if (!webviewLoaded) {
return;
}

if (reloadCounter > 0) {
logger.debug('Reloading with counter set to', reloadCounter);
}

await loadPlugins({
pluginRunner,
pluginSettings,
Expand All @@ -56,7 +70,7 @@ const usePlugins = (
if (!event.cancelled) {
reloadAllRef.current = false;
}
}, [pluginRunner, store, webviewLoaded, pluginSettings]);
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
};

const useUnloadPluginsOnGlobalDisable = (
Expand All @@ -79,6 +93,7 @@ interface Props {
serializedPluginSettings: SerializedPluginSettings;
pluginSupportEnabled: boolean;
pluginStates: PluginStates;
devPluginPath: string;
pluginHtmlContents: PluginHtmlContents;
themeId: number;
}
Expand All @@ -98,7 +113,7 @@ const PluginRunnerWebViewComponent: React.FC<Props> = props => {
}, [webviewReloadCounter]);

const pluginSettings = usePluginSettings(props.serializedPluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings);
usePlugins(pluginRunner, webviewLoaded, pluginSettings, props.pluginSupportEnabled, props.devPluginPath);
useUnloadPluginsOnGlobalDisable(props.pluginStates, props.pluginSupportEnabled);

const onLoadStart = useCallback(() => {
Expand Down Expand Up @@ -183,6 +198,7 @@ export default connect((state: AppState) => {
const result: Props = {
serializedPluginSettings: state.settings['plugins.states'],
pluginSupportEnabled: state.settings['plugins.pluginSupportEnabled'],
devPluginPath: state.settings['plugins.devPluginPaths'],
pluginStates: state.pluginService.plugins,
pluginHtmlContents: state.pluginService.pluginHtmlContents,
themeId: state.settings.theme,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
import { basename, join } from 'path';
import { useRef } from 'react';

type OnDevPluginChange = (id: string)=> void;

const useOnDevPluginsUpdated = (onDevPluginChange: OnDevPluginChange, devPluginPath: string, pluginSupportEnabled: boolean) => {
const onDevPluginChangeRef = useRef(onDevPluginChange);
onDevPluginChangeRef.current = onDevPluginChange;
const isFirstUpdateRef = useRef(true);

useAsyncEffect(async (event) => {
if (!devPluginPath || !pluginSupportEnabled) return;

const itemToLastModTime = new Map<string, number>();

// publishPath should point to the publish/ subfolder of a plugin's development
// directory.
const checkPluginChange = async (pluginPublishPath: string) => {
const dirStats = await shim.fsDriver().readDirStats(pluginPublishPath);
let hasChange = false;
let changedPluginId = '';
for (const item of dirStats) {
if (item.path.endsWith('.jpl')) {
const lastModTime = itemToLastModTime.get(item.path);
const modTime = item.mtime.getTime();
if (lastModTime === undefined || lastModTime < modTime) {
itemToLastModTime.set(item.path, modTime);
hasChange = true;
changedPluginId = basename(item.path, '.jpl');
break;
}
}
}

if (hasChange) {
if (isFirstUpdateRef.current) {
// Avoid sending an event the first time the hook is called. The first iteration
// collects initial timestamp information. In that case, hasChange
// will always be true, even with no plugin reload.
isFirstUpdateRef.current = false;
} else {
onDevPluginChangeRef.current(changedPluginId);
}
}
};

while (!event.cancelled) {
const publishFolder = join(devPluginPath, 'publish');
await checkPluginChange(publishFolder);

const pollingIntervalSeconds = 5;
await time.sleep(pollingIntervalSeconds);
}
}, [devPluginPath, pluginSupportEnabled]);
};

export default useOnDevPluginsUpdated;
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { Platform, Linking, View, Switch, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
import { Platform, Linking, View, ScrollView, Text, TouchableOpacity, Alert, PermissionsAndroid, Dimensions, AccessibilityInfo } from 'react-native';
import Setting, { AppType, SettingMetadataSection } from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService';
import SearchEngine from '@joplin/lib/services/search/SearchEngine';
Expand All @@ -12,7 +12,6 @@ import { connect } from 'react-redux';
import ScreenHeader from '../../ScreenHeader';
import { _ } from '@joplin/lib/locale';
import BaseScreenComponent from '../../base-screen';
import { themeStyle } from '../../global-style';
import * as shared from '@joplin/lib/components/shared/config/config-shared';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
Expand All @@ -36,6 +35,8 @@ import EnablePluginSupportPage from './plugins/EnablePluginSupportPage';
import getVersionInfoText from '../../../utils/getVersionInfoText';
import JoplinCloudConfig, { emailToNoteDescription, emailToNoteLabel } from './JoplinCloudConfig';
import shim from '@joplin/lib/shim';
import SettingsToggle from './SettingsToggle';
import { UpdateSettingValueCallback } from './types';

interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Expand Down Expand Up @@ -673,22 +674,16 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
);
}

// eslint-disable-next-line @typescript-eslint/ban-types, @typescript-eslint/no-explicit-any -- Old code before rule was applied, Old code before rule was applied
private renderToggle(key: string, label: string, value: any, updateSettingValue: Function, descriptionComp: any = null) {
const theme = themeStyle(this.props.themeId);

return (
<View key={key}>
<View style={this.styles().getContainerStyle(false)}>
<Text key="label" style={this.styles().styleSheet.switchSettingText}>
{label}
</Text>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied */}
<Switch key="control" style={this.styles().styleSheet.switchSettingControl} trackColor={{ false: theme.dividerColor }} value={value} onValueChange={(value: any) => void updateSettingValue(key, value)} />
</View>
{descriptionComp}
</View>
);
private renderToggle(key: string, label: string, value: unknown, updateSettingValue: UpdateSettingValueCallback) {
return <SettingsToggle
key={key}
settingId={key}
value={value}
label={label}
updateSettingValue={updateSettingValue}
styles={this.styles()}
themeId={this.props.themeId}
/>;
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,23 @@ import * as React from 'react';
import shim from '@joplin/lib/shim';
import { FunctionComponent, useCallback, useEffect, useState } from 'react';
import { ConfigScreenStyles } from './configScreenStyles';
import { View, Text } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import Setting, { SettingItem } from '@joplin/lib/models/Setting';
import { openDocumentTree } from '@joplin/react-native-saf-x';
import { UpdateSettingValueCallback } from './types';
import { reg } from '@joplin/lib/registry';
import type FsDriverWeb from '../../../utils/fs-driver/fs-driver-rn.web';
import { TouchableRipple } from 'react-native-paper';
import { IconButton, TouchableRipple } from 'react-native-paper';
import { _ } from '@joplin/lib/locale';

type Mode = 'read'|'readwrite';

interface Props {
themeId: number;
styles: ConfigScreenStyles;
settingMetadata: SettingItem;
mode: 'read'|'readwrite';
mode: Mode;
description: React.ReactNode|null;
updateSettingValue: UpdateSettingValueCallback;
}

Expand All @@ -23,63 +28,107 @@ type ExtendedSelf = (typeof window.self) & {
};
declare const self: ExtendedSelf;

const FileSystemPathSelector: FunctionComponent<Props> = props => {
const useFileSystemPath = (settingId: string, updateSettingValue: UpdateSettingValueCallback, accessMode: Mode) => {
const [fileSystemPath, setFileSystemPath] = useState<string>('');

const settingId = props.settingMetadata.key;

useEffect(() => {
setFileSystemPath(Setting.value(settingId));
}, [settingId]);

const selectDirectoryButtonPress = useCallback(async () => {
const showDirectoryPicker = useCallback(async () => {
if (shim.mobilePlatform() === 'web') {
// Directory picker IDs can't include certain characters.
const pickerId = `setting-${settingId}`.replace(/[^a-zA-Z]/g, '_');
const handle = await self.showDirectoryPicker({ id: pickerId, mode: props.mode });
const handle = await self.showDirectoryPicker({ id: pickerId, mode: accessMode });
const fsDriver = shim.fsDriver() as FsDriverWeb;
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, props.mode);
await props.updateSettingValue(settingId, uri);
const uri = await fsDriver.mountExternalDirectory(handle, pickerId, accessMode);
await updateSettingValue(settingId, uri);
setFileSystemPath(uri);
} else {
try {
const doc = await openDocumentTree(true);
if (doc?.uri) {
setFileSystemPath(doc.uri);
await props.updateSettingValue(settingId, doc.uri);
await updateSettingValue(settingId, doc.uri);
} else {
throw new Error('User cancelled operation');
}
} catch (e) {
reg.logger().info('Didn\'t pick sync dir: ', e);
}
}
}, [props.updateSettingValue, settingId, props.mode]);
}, [updateSettingValue, settingId, accessMode]);

const clearPath = useCallback(() => {
setFileSystemPath('');
void updateSettingValue(settingId, '');
}, [updateSettingValue, settingId]);

// Supported on Android and some versions of Chrome
const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self);
if (!supported) {
return null;
}

return { clearPath, showDirectoryPicker, fileSystemPath, supported };
};

const pathSelectorStyles = StyleSheet.create({
innerContainer: {
paddingTop: 0,
paddingBottom: 0,
paddingLeft: 0,
paddingRight: 0,
},
mainButton: {
flexGrow: 1,
flexShrink: 1,
paddingHorizontal: 16,
paddingVertical: 22,
margin: 0,
},
buttonContent: {
flexDirection: 'row',
},
});

const FileSystemPathSelector: FunctionComponent<Props> = props => {
const settingId = props.settingMetadata.key;
const { clearPath, showDirectoryPicker, fileSystemPath, supported } = useFileSystemPath(settingId, props.updateSettingValue, props.mode);

const styleSheet = props.styles.styleSheet;

return (
const clearButton = (
<IconButton
icon='delete'
accessibilityLabel={_('Clear')}
onPress={clearPath}
/>
);

const containerStyles = props.styles.getContainerStyle(!!props.description);

const control = <View style={[containerStyles.innerContainer, pathSelectorStyles.innerContainer]}>
<TouchableRipple
onPress={selectDirectoryButtonPress}
style={styleSheet.settingContainer}
onPress={showDirectoryPicker}
style={pathSelectorStyles.mainButton}
role='button'
>
<View style={styleSheet.settingContainer}>
<View style={pathSelectorStyles.buttonContent}>
<Text key="label" style={styleSheet.settingText}>
{props.settingMetadata.label()}
</Text>
<Text style={styleSheet.settingControl}>
<Text style={styleSheet.settingControl} numberOfLines={1}>
{fileSystemPath}
</Text>
</View>
</TouchableRipple>
);
{fileSystemPath ? clearButton : null}
</View>;

if (!supported) return null;

return <View style={containerStyles.outerContainer}>
{control}
{props.description}
</View>;
};

export default FileSystemPathSelector;
Loading

0 comments on commit 98fce34

Please sign in to comment.