mirror of https://github.com/laurent22/joplin.git
Web: Add support for auto-reloading dev plugins on change (#11545)
parent
a81af0711c
commit
98fce34fe9
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
@ -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,
|
||||
|
@ -56,7 +70,7 @@ const usePlugins = (
|
|||
if (!event.cancelled) {
|
||||
reloadAllRef.current = false;
|
||||
}
|
||||
}, [pluginRunner, store, webviewLoaded, pluginSettings]);
|
||||
}, [pluginRunner, store, webviewLoaded, pluginSettings, reloadCounter]);
|
||||
};
|
||||
|
||||
const useUnloadPluginsOnGlobalDisable = (
|
||||
|
@ -79,6 +93,7 @@ interface Props {
|
|||
serializedPluginSettings: SerializedPluginSettings;
|
||||
pluginSupportEnabled: boolean;
|
||||
pluginStates: PluginStates;
|
||||
devPluginPath: string;
|
||||
pluginHtmlContents: PluginHtmlContents;
|
||||
themeId: number;
|
||||
}
|
||||
|
@ -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(() => {
|
||||
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -23,30 +28,28 @@ 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');
|
||||
}
|
||||
|
@ -54,32 +57,78 @@ const FileSystemPathSelector: FunctionComponent<Props> = props => {
|
|||
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;
|
||||
|
|
|
@ -38,7 +38,7 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
|
|||
const styleSheet = props.styles.styleSheet;
|
||||
|
||||
const descriptionComp = !settingDescription ? null : <Text style={styleSheet.settingDescriptionText}>{settingDescription}</Text>;
|
||||
const containerStyle = props.styles.getContainerStyle(!!settingDescription);
|
||||
const containerStyles = props.styles.getContainerStyle(!!settingDescription);
|
||||
|
||||
const labelId = useId();
|
||||
|
||||
|
@ -49,8 +49,8 @@ const SettingComponent: React.FunctionComponent<Props> = props => {
|
|||
const label = md.label();
|
||||
|
||||
return (
|
||||
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
|
||||
<View style={containerStyle}>
|
||||
<View key={props.settingId} style={containerStyles.outerContainer}>
|
||||
<View style={containerStyles.innerContainer}>
|
||||
<Text key="label" style={styleSheet.settingText}>
|
||||
{label}
|
||||
</Text>
|
||||
|
@ -125,17 +125,19 @@ 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}
|
||||
updateSettingValue={props.updateSettingValue}
|
||||
description={descriptionComp}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View key={props.settingId} style={{ flexDirection: 'column', borderBottomWidth: 1, borderBottomColor: theme.dividerColor }}>
|
||||
<View key={props.settingId} style={containerStyle}>
|
||||
<View key={props.settingId} style={containerStyles.outerContainer}>
|
||||
<View key={props.settingId} style={containerStyles.innerContainer}>
|
||||
<Text key="label" style={styleSheet.settingText} nativeID={labelId}>
|
||||
{md.label()}
|
||||
</Text>
|
||||
|
|
|
@ -24,9 +24,11 @@ const SettingsToggle: FunctionComponent<Props> = props => {
|
|||
const theme = themeStyle(props.themeId);
|
||||
const styleSheet = props.styles.styleSheet;
|
||||
|
||||
const containerStyles = props.styles.getContainerStyle(!!props.description);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<View style={props.styles.getContainerStyle(false)}>
|
||||
<View style={containerStyles.outerContainer}>
|
||||
<View style={containerStyles.innerContainer}>
|
||||
<Text key="label" style={styleSheet.switchSettingText}>
|
||||
{props.label}
|
||||
</Text>
|
||||
|
|
|
@ -6,8 +6,11 @@ type SidebarButtonStyle = ViewStyle & { height: number };
|
|||
export interface ConfigScreenStyleSheet {
|
||||
body: ViewStyle;
|
||||
|
||||
settingOuterContainer: ViewStyle;
|
||||
settingOuterContainerNoBorder: ViewStyle;
|
||||
settingContainer: ViewStyle;
|
||||
settingContainerNoBottomBorder: ViewStyle;
|
||||
|
||||
headerWrapperStyle: ViewStyle;
|
||||
|
||||
headerTextStyle: TextStyle;
|
||||
|
@ -39,12 +42,17 @@ export interface ConfigScreenStyleSheet {
|
|||
settingControl: TextStyle;
|
||||
}
|
||||
|
||||
interface ContainerStyles {
|
||||
outerContainer: ViewStyle;
|
||||
innerContainer: ViewStyle;
|
||||
}
|
||||
|
||||
export interface ConfigScreenStyles {
|
||||
styleSheet: ConfigScreenStyleSheet;
|
||||
|
||||
selectedSectionButtonColor: string;
|
||||
keyboardAppearance: 'default'|'light'|'dark';
|
||||
getContainerStyle(hasDescription: boolean): ViewStyle;
|
||||
getContainerStyle(hasDescription: boolean): ContainerStyles;
|
||||
}
|
||||
|
||||
const configScreenStyles = (themeId: number): ConfigScreenStyles => {
|
||||
|
@ -107,6 +115,14 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
|
|||
justifyContent: 'flex-start',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
settingOuterContainer: {
|
||||
flexDirection: 'column',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.dividerColor,
|
||||
},
|
||||
settingOuterContainerNoBorder: {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
settingContainer: settingContainerStyle,
|
||||
settingContainerNoBottomBorder: {
|
||||
...settingContainerStyle,
|
||||
|
@ -229,7 +245,9 @@ const configScreenStyles = (themeId: number): ConfigScreenStyles => {
|
|||
selectedSectionButtonColor: theme.selectedColor,
|
||||
keyboardAppearance: theme.keyboardAppearance,
|
||||
getContainerStyle: (hasDescription) => {
|
||||
return !hasDescription ? styleSheet.settingContainer : styleSheet.settingContainerNoBottomBorder;
|
||||
const outerContainer = hasDescription ? styleSheet.settingOuterContainer : styleSheet.settingOuterContainerNoBorder;
|
||||
const innerContainer = hasDescription ? styleSheet.settingContainerNoBottomBorder : styleSheet.settingContainer;
|
||||
return { outerContainer, innerContainer };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -91,7 +91,7 @@ const PluginUploadButton: React.FC<Props> = props => {
|
|||
}, [props.pluginSettings, props.updatePluginStates]);
|
||||
|
||||
return (
|
||||
<View style={props.styles.getContainerStyle(false)}>
|
||||
<View style={props.styles.getContainerStyle(false).innerContainer}>
|
||||
<TextButton
|
||||
type={ButtonType.Primary}
|
||||
onPress={onInstallFromFile}
|
||||
|
|
|
@ -8,7 +8,7 @@ export interface CustomSettingSection {
|
|||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
export type UpdateSettingValueCallback = (key: string, value: any)=> Promise<void>;
|
||||
export type UpdateSettingValueCallback = (key: string, value: any)=> void|Promise<void>;
|
||||
|
||||
export interface PluginStatusRecord {
|
||||
[pluginId: string]: boolean;
|
||||
|
|
|
@ -929,9 +929,23 @@ 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: (settings) => {
|
||||
if (shim.isElectron()) return true;
|
||||
if (shim.mobilePlatform() !== 'web') return false;
|
||||
|
||||
const pluginSupportEnabled = settings['plugins.pluginSupportEnabled'];
|
||||
return !!pluginSupportEnabled;
|
||||
},
|
||||
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.',
|
||||
description: () => {
|
||||
if (shim.mobilePlatform()) {
|
||||
return 'The path to a plugin\'s development directory. When the plugin is rebuilt, Joplin reloads the plugin automatically.';
|
||||
} else {
|
||||
return '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,
|
||||
},
|
||||
|
||||
|
|
|
@ -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)}`);
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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.');
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
@ -32,6 +32,20 @@ After loading, plugins are run in an `<iframe>` with an `about:srcdoc` URL. To v
|
|||
- The JavaScript context will be named `about:srcdoc`.
|
||||
- If using Chrome's DevTools, the [`debug`](https://developer.chrome.com/docs/devtools/console/utilities#debug-function) and other console utility function may be helpful.
|
||||
|
||||
## Web: Automatic reloading
|
||||
|
||||
In some browsers, the web version of the mobile app supports development plugins. Development plugins automatically reload when changed on disk. Non-development plugins must be re-installed when changed.
|
||||
|
||||
To add a development plugin:
|
||||
1. Open the web version of Joplin mobile in a Chromium-based browser.
|
||||
- Development plugins are loaded with `showOpenFilePicker`. As of early 2025, [only Chrome and several Chromium-based browsers support this API](https://developer.mozilla.org/en-US/docs/Web/API/Window/showOpenFilePicker#browser_compatibility).
|
||||
2. Open Configuration > Plugins > Advanced.
|
||||
3. Click "Development plugins".
|
||||
4. Select a plugin folder.
|
||||
- This folder should contain the `publish`, `dist`, and `src` folders for the plugin.
|
||||
5. Click "save".
|
||||
|
||||
**Note**: Markdown editor plugins may not fully reload unless either the page is reloaded or the Markdown editor is closed and re-opened.
|
||||
|
||||
## Android: Inspecting a WebView
|
||||
|
||||
|
|
Loading…
Reference in New Issue