Skip to content

Commit

Permalink
WIP: Web: Live-reload dev plugins on change
Browse files Browse the repository at this point in the history
  • Loading branch information
personalizedrefrigerator committed Dec 19, 2024
1 parent d9df2dc commit a96d654
Show file tree
Hide file tree
Showing 15 changed files with 154 additions and 19 deletions.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,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 @@ -675,6 +675,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(() => {
logger.info('Dev plugin updated. Reloading...');
reloadAllRef.current = true;
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,50 @@
import useAsyncEffect from '@joplin/lib/hooks/useAsyncEffect';
import shim from '@joplin/lib/shim';
import time from '@joplin/lib/time';
import { join } from 'path';
import { useRef } from 'react';

type OnDevPluginChange = ()=> 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>();

while (!event.cancelled) {
const publishFolder = join(devPluginPath, 'publish');
const dirStats = await shim.fsDriver().readDirStats(publishFolder);
let hasChange = false;
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;
}
}
}

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();
}
}

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

export default useOnDevPluginsUpdated;
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,18 @@ 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 { _ } from '@joplin/lib/locale';
import IconButton from '../../IconButton';

interface Props {
themeId: number;
styles: ConfigScreenStyles;
settingMetadata: SettingItem;
mode: 'read'|'readwrite';
Expand All @@ -23,6 +26,24 @@ type ExtendedSelf = (typeof window.self) & {
};
declare const self: ExtendedSelf;

const styles = StyleSheet.create({
container: {
paddingTop: 0,
paddingLeft: 0,
paddingRight: 0,
paddingBottom: 0,
},
mainButton: {
flexGrow: 1,
flexShrink: 1,
padding: 22,
margin: 0,
},
buttonContent: {
flexDirection: 'row',
},
});

const FileSystemPathSelector: FunctionComponent<Props> = props => {
const [fileSystemPath, setFileSystemPath] = useState<string>('');

Expand Down Expand Up @@ -56,6 +77,11 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
}
}, [props.updateSettingValue, settingId, props.mode]);

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

// Supported on Android and some versions of Chrome
const supported = shim.fsDriver().isUsingAndroidSAF() || (shim.mobilePlatform() === 'web' && 'showDirectoryPicker' in self);
if (!supported) {
Expand All @@ -64,22 +90,34 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {

const styleSheet = props.styles.styleSheet;

return (
const clearButton = (
<IconButton
iconName='material delete'
description={_('Clear')}
iconStyle={styleSheet.iconButtonText}
contentWrapperStyle={styleSheet.iconButton}
themeId={props.themeId}
onPress={clearPathButtonPress}
/>
);

return <View style={[styleSheet.settingContainer, styles.container]}>
<TouchableRipple
onPress={selectDirectoryButtonPress}
style={styleSheet.settingContainer}
style={styles.mainButton}
role='button'
>
<View style={styleSheet.settingContainer}>
<View style={styles.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>;
};

export default FileSystemPathSelector;
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
if (['sync.2.path', 'plugins.devPluginPaths'].includes(md.key) && (shim.fsDriver().isUsingAndroidSAF() || shim.mobilePlatform() === 'web')) {
return (
<FileSystemPathSelector
themeId={props.themeId}
mode={md.key === 'sync.2.path' ? 'readwrite' : 'read'}
styles={props.styles}
settingMetadata={md}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface ConfigScreenStyleSheet {
switchSettingContainer: ViewStyle;
switchSettingControl: TextStyle;

iconButton: ViewStyle;
iconButtonText: TextStyle;

sidebarButton: SidebarButtonStyle;
sidebarIcon: TextStyle;
selectedSidebarButton: SidebarButtonStyle;
Expand Down Expand Up @@ -164,6 +167,16 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
textInput: {
color: theme.color,
},
iconButton: {
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
},
iconButtonText: {
fontSize: theme.fontSizeLarge,
color: theme.color,
},

switchSettingText: {
...settingTextStyle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,12 +92,20 @@ const PluginChips: React.FC<Props> = props => {
return <PluginChip faded={true}>{_('Installed')}</PluginChip>;
};

const renderDevChip = () => {
if (!item.devMode) {
return null;
}
return <PluginChip faded={true}>{_('Dev')}</PluginChip>;
};

return <View style={containerStyle}>
{renderIncompatibleChip()}
{renderInstalledChip()}
{renderErrorsChip()}
{renderBuiltInChip()}
{renderUpdatableChip()}
{renderDevChip()}
{renderDisabledChip()}
</View>;
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ const PluginInfoModalContent: React.FC<Props> = props => {
item={item}
type={ButtonType.Delete}
onPress={props.pluginCallbacks.onDelete}
disabled={item.builtIn || (item?.deleted ?? true)}
disabled={item.builtIn || item.devMode || (item?.deleted ?? true)}
title={item?.deleted ? _('Deleted') : _('Delete')}
/>
);
Expand Down
4 changes: 3 additions & 1 deletion packages/lib/models/settings/builtInMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,7 +929,9 @@ const builtInMetadata = (Setting: typeof SettingType) => {
section: 'plugins',
public: true,
advanced: true,
appTypes: [AppType.Desktop],
appTypes: [AppType.Desktop, AppType.Mobile],
// For now, development plugins are only enabled on desktop & web.
show: () => shim.isElectron() || shim.mobilePlatform() === 'web',
label: () => 'Development plugins',
description: () => 'You may add multiple plugin paths, each separated by a comma. You will need to restart the application for the changes to take effect.',
storage: SettingStorage.File,
Expand Down
4 changes: 4 additions & 0 deletions packages/lib/services/CommandService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ export default class CommandService extends BaseService {
};
}

public unregisterDeclaration(name: string) {
delete this.commands_[name];
}

public registerRuntime(commandName: string, runtime: CommandRuntime, allowMultiple = false): RegisteredRuntime {
if (typeof commandName !== 'string') throw new Error(`Command name must be a string. Got: ${JSON.stringify(commandName)}`);

Expand Down
12 changes: 7 additions & 5 deletions packages/lib/services/commands/ToolbarButtonUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,22 +48,24 @@ export default class ToolbarButtonUtils {
private commandToToolbarButton(commandName: string, whenClauseContext: WhenClauseContext): ToolbarButtonInfo {
const newEnabled = this.service.isEnabled(commandName, whenClauseContext);
const newTitle = this.service.title(commandName);
const newIcon = this.service.iconName(commandName);
const newLabel = this.service.label(commandName);

if (
this.toolbarButtonCache_[commandName] &&
this.toolbarButtonCache_[commandName].info.enabled === newEnabled &&
this.toolbarButtonCache_[commandName].info.title === newTitle
this.toolbarButtonCache_[commandName].info.title === newTitle &&
this.toolbarButtonCache_[commandName].info.iconName === newIcon &&
this.toolbarButtonCache_[commandName].info.tooltip === newLabel
) {
return this.toolbarButtonCache_[commandName].info;
}

const command = this.service.commandByName(commandName, { runtimeMustBeRegistered: true });

const output: ToolbarButtonInfo = {
type: 'button',
name: commandName,
tooltip: this.service.label(commandName),
iconName: command.declaration.iconName,
tooltip: newLabel,
iconName: newIcon,
enabled: newEnabled,
onClick: async () => {
await this.service.execute(commandName);
Expand Down
1 change: 1 addition & 0 deletions packages/lib/services/plugins/api/JoplinCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export default class JoplinCommands {
CommandService.instance().registerRuntime(declaration.name, runtime);
this.plugin_.addOnUnloadListener(() => {
CommandService.instance().unregisterRuntime(declaration.name);
CommandService.instance().unregisterDeclaration(declaration.name);
});
}

Expand Down
5 changes: 1 addition & 4 deletions packages/lib/services/plugins/loadPlugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,7 @@ const loadPlugins = async ({
}
}

if (Setting.value('env') === 'dev') {
logger.info('Running dev plugins (if any)...');
await pluginService.loadAndRunDevPlugins(pluginSettings);
}
await pluginService.loadAndRunDevPlugins(pluginSettings);

if (cancelEvent.cancelled) {
logger.info('Cancelled.');
Expand Down
1 change: 1 addition & 0 deletions packages/lib/services/plugins/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ const reducer = (draftRoot: Draft<any>, action: any) => {

case 'PLUGIN_UNLOAD':
delete draft.plugins[action.pluginId];
delete draft.pluginHtmlContents[action.pluginId];
break;

}
Expand Down

0 comments on commit a96d654

Please sign in to comment.