From 97b3202c25c018b37cef734eb1567e492a2cf05c Mon Sep 17 00:00:00 2001 From: Harsh16gupta Date: Sun, 16 Aug 2026 11:55:06 +0530 Subject: [PATCH] fix: persist webview state during apply and undo operations (v0.1.9) --- package.json | 2 +- src/manifest.json | 2 +- src/panel/setupPanel.ts | 32 +++ src/settings/registerSettings.ts | 15 +- src/types/panel.ts | 20 +- src/webview/context/AppStateContext.tsx | 237 ++++++++++-------- src/webview/pages/DashboardPage.tsx | 21 +- src/webview/pages/EmptyStatePage.tsx | 27 +- test/panel/setupPanel.test.ts | 312 ++++++++++++++++++++++++ 9 files changed, 550 insertions(+), 118 deletions(-) create mode 100644 test/panel/setupPanel.test.ts diff --git a/package.json b/package.json index 4417ecc..ff72f2a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "joplin-plugin-note-categorization", - "version": "0.1.8", + "version": "0.1.9", "scripts": { "dist": "webpack --env joplin-plugin-config=buildMain && webpack --env joplin-plugin-config=buildExtraScripts && npm run copyAssets && webpack --env joplin-plugin-config=createArchive", "prepare": "npm run dist", diff --git a/src/manifest.json b/src/manifest.json index 34a0a7e..d285e31 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 1, "id": "com.harsh16gupta.notecategorization", "app_min_version": "3.5", - "version": "0.1.8", + "version": "0.1.9", "name": "Note Categorization Plugin", "description": "AI-based note categorisation: clusters notes semantically, suggests tags and notebook structures, and detects stale notes.", "author": "Harsh Gupta", diff --git a/src/panel/setupPanel.ts b/src/panel/setupPanel.ts index a590fab..d384a5d 100644 --- a/src/panel/setupPanel.ts +++ b/src/panel/setupPanel.ts @@ -16,6 +16,10 @@ export async function setupPanel(operationState: OperationState): Promise { + panelState = state; + }; + let lastResultsState: { strategies: BenchmarkResult[]; notes: PanelNote[]; @@ -62,6 +66,16 @@ export async function setupPanel(operationState: OperationState): Promise void; } const OP_IN_PROGRESS_MSG = 'An operation is already in progress. Please wait for it to complete.'; +/** + * Executes undo operation triggered natively (from Tools menu or Joplin Settings). + * Updates backend panelState via operationState.setPanelState so that when the webview + * remounts, getInitialState surfaces the undo status/completion banner. + * Direct modal user feedback during native options execution is provided via showMessageBox. + */ export async function runNativeUndo(source: string, operationState: OperationState): Promise { if (operationState.inProgress) { await joplin.views.dialogs.showMessageBox(OP_IN_PROGRESS_MSG); return; } operationState.inProgress = true; + operationState.setPanelState?.({ type: 'undo_status', text: 'Initializing undo...' }); try { let lastMessage = ''; await undoCategorizationChanges((state) => { + operationState.setPanelState?.(state); log(`Native ${source} Undo: ${'text' in state ? state.text : state.type}`); if (state.type === 'undo_complete') { lastMessage = 'Reverted categorization changes successfully!'; @@ -29,7 +40,9 @@ export async function runNativeUndo(source: string, operationState: OperationSta await joplin.views.dialogs.showMessageBox(lastMessage); } } catch (err) { - await joplin.views.dialogs.showMessageBox(`Undo failed: ${err instanceof Error ? err.message : String(err)}`); + const errMsg = err instanceof Error ? err.message : String(err); + operationState.setPanelState?.({ type: 'undo_error', message: errMsg }); + await joplin.views.dialogs.showMessageBox(`Undo failed: ${errMsg}`); } finally { operationState.inProgress = false; } diff --git a/src/types/panel.ts b/src/types/panel.ts index 6bbdb41..ec8ca9c 100644 --- a/src/types/panel.ts +++ b/src/types/panel.ts @@ -28,6 +28,16 @@ export interface ApplyMessage { clusterTags: { [clusterId: number]: string[] }; } +export type ApplyUndoPanelMessage = + | { type: 'apply_status'; text: string } + | { type: 'apply_progress'; current: number; total: number } + | { type: 'apply_complete' } + | { type: 'apply_error'; message: string } + | { type: 'undo_status'; text: string } + | { type: 'undo_progress'; current: number; total: number } + | { type: 'undo_complete' } + | { type: 'undo_error'; message: string }; + // Plugin → Webview export type PanelMessage = | { type: 'status'; text: string; isNativeAiUsed?: boolean } @@ -39,17 +49,11 @@ export type PanelMessage = selectedStrategyIndex?: number; isNativeAiUsed?: boolean; isAiNamingUsed?: boolean; + panelState?: ApplyUndoPanelMessage; /* eslint-disable-next-line no-mixed-spaces-and-tabs */ } | { type: 'error'; message: string } - | { type: 'apply_status'; text: string } - | { type: 'apply_progress'; current: number; total: number } - | { type: 'apply_complete' } - | { type: 'apply_error'; message: string } - | { type: 'undo_status'; text: string } - | { type: 'undo_progress'; current: number; total: number } - | { type: 'undo_complete' } - | { type: 'undo_error'; message: string }; + | ApplyUndoPanelMessage; // Webview → Plugin export type WebviewMessage = diff --git a/src/webview/context/AppStateContext.tsx b/src/webview/context/AppStateContext.tsx index 2f9636b..0bb4651 100644 --- a/src/webview/context/AppStateContext.tsx +++ b/src/webview/context/AppStateContext.tsx @@ -120,109 +120,124 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil const handlePollResponse = React.useCallback( (msg: PanelMessage | { type: 'idle' }) => { - if (!msg || !msg.type) return; - - switch (msg.type) { - case 'status': - setStatusText(msg.text || ''); - if (typeof msg.isNativeAiUsed === 'boolean') { - setIsNativeAiUsed(msg.isNativeAiUsed); - } - break; - - case 'progress': - setProgress({ - current: msg.current || 0, - total: msg.total || 0, - cached: msg.cached || 0, - skipped: msg.skipped || 0, - }); - if (typeof msg.isNativeAiUsed === 'boolean') { - setIsNativeAiUsed(msg.isNativeAiUsed); - } - break; - - case 'results': { - stopPolling(); - setIsRunning(false); - setStrategies(msg.strategies || []); - setNotes(msg.notes || []); - const kmeansIdx = (msg.strategies || []).findIndex((s: BenchmarkResult) => - s.strategyName.startsWith('kmeans'), - ); - const defaultIdx = kmeansIdx !== -1 ? kmeansIdx : 0; - setSelectedStrategyIndex(msg.selectedStrategyIndex ?? defaultIdx); - if (typeof msg.isNativeAiUsed === 'boolean') { - setIsNativeAiUsed(msg.isNativeAiUsed); - } - if (typeof msg.isAiNamingUsed === 'boolean') { - setIsAiNamingUsed(msg.isAiNamingUsed); + const processMessage = (m: PanelMessage | { type: 'idle' }) => { + if (!m || !m.type) return; + + switch (m.type) { + case 'status': + setIsRunning(true); + setStatusText(m.text || ''); + if (typeof m.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(m.isNativeAiUsed); + } + break; + + case 'progress': + setIsRunning(true); + setProgress({ + current: m.current || 0, + total: m.total || 0, + cached: m.cached || 0, + skipped: m.skipped || 0, + }); + if (typeof m.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(m.isNativeAiUsed); + } + break; + + case 'results': { + stopPolling(); + setIsRunning(false); + setStrategies(m.strategies || []); + setNotes(m.notes || []); + const kmeansIdx = (m.strategies || []).findIndex((s: BenchmarkResult) => + s.strategyName.startsWith('kmeans'), + ); + const defaultIdx = kmeansIdx !== -1 ? kmeansIdx : 0; + setSelectedStrategyIndex(m.selectedStrategyIndex ?? defaultIdx); + if (typeof m.isNativeAiUsed === 'boolean') { + setIsNativeAiUsed(m.isNativeAiUsed); + } + if (typeof m.isAiNamingUsed === 'boolean') { + setIsAiNamingUsed(m.isAiNamingUsed); + } + setError(null); + setActiveView('dashboard'); + if (m.panelState) { + processMessage(m.panelState); + } + break; } - setError(null); - setActiveView('dashboard'); - break; + + case 'error': + stopPolling(); + setIsRunning(false); + setError(m.message || 'An unknown error occurred.'); + break; + + case 'apply_status': + setIsApplying(true); + setApplyError(null); + setApplySuccess(false); + setUndoSuccess(false); + setUndoError(null); + break; + + case 'apply_progress': + setIsApplying(true); + setApplyProgress({ + current: m.current || 0, + total: m.total || 0, + }); + break; + + case 'apply_complete': + stopPolling(); + setIsApplying(false); + setApplySuccess(true); + setUndoSuccess(false); + fetchSettings(); + break; + + case 'apply_error': + stopPolling(); + setIsApplying(false); + setApplyError(m.message || 'An unknown error occurred.'); + break; + + case 'undo_status': + setIsUndoing(true); + setUndoError(null); + setUndoSuccess(false); + setApplySuccess(false); + setApplyError(null); + break; + + case 'undo_progress': + setIsUndoing(true); + setUndoProgress({ + current: m.current || 0, + total: m.total || 0, + }); + break; + + case 'undo_complete': + stopPolling(); + setIsUndoing(false); + setUndoSuccess(true); + setApplySuccess(false); + fetchSettings(); + break; + + case 'undo_error': + stopPolling(); + setIsUndoing(false); + setUndoError(m.message || 'An unknown error occurred.'); + break; } + }; - case 'error': - stopPolling(); - setIsRunning(false); - setError(msg.message || 'An unknown error occurred.'); - break; - - case 'apply_status': - setIsApplying(true); - setApplyError(null); - setApplySuccess(false); - break; - - case 'apply_progress': - setIsApplying(true); - setApplyProgress({ - current: msg.current || 0, - total: msg.total || 0, - }); - break; - - case 'apply_complete': - stopPolling(); - setIsApplying(false); - setApplySuccess(true); - fetchSettings(); - break; - - case 'apply_error': - stopPolling(); - setIsApplying(false); - setApplyError(msg.message || 'An unknown error occurred.'); - break; - - case 'undo_status': - setIsUndoing(true); - setUndoError(null); - setUndoSuccess(false); - break; - - case 'undo_progress': - setIsUndoing(true); - setUndoProgress({ - current: msg.current || 0, - total: msg.total || 0, - }); - break; - - case 'undo_complete': - stopPolling(); - setIsUndoing(false); - setUndoSuccess(true); - fetchSettings(); - break; - - case 'undo_error': - stopPolling(); - setIsUndoing(false); - setUndoError(msg.message || 'An unknown error occurred.'); - break; - } + processMessage(msg); }, [ stopPolling, @@ -248,6 +263,11 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil ], ); + const handlePollResponseRef = React.useRef(handlePollResponse); + React.useEffect(() => { + handlePollResponseRef.current = handlePollResponse; + }, [handlePollResponse]); + const startPolling = React.useCallback(() => { stopPolling(); pollIntervalRef.current = setInterval(async () => { @@ -255,13 +275,13 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil try { const state = await webviewApi.postMessage({ type: 'poll' }); if (state) { - handlePollResponse(state); + handlePollResponseRef.current(state); } } catch (err) { console.error('Polling error:', err); } }, POLL_INTERVAL_MS); - }, [stopPolling, handlePollResponse]); + }, [stopPolling]); React.useEffect(() => { fetchSettings(); @@ -271,7 +291,18 @@ export const AppStateProvider: React.FC<{ children: React.ReactNode }> = ({ chil .then((initialState) => { if (initialState) { handlePollResponse(initialState); - if (initialState.type === 'status' || initialState.type === 'progress') { + const activeState = + initialState.type === 'results' && initialState.panelState + ? initialState.panelState + : initialState; + if ( + activeState.type === 'status' || + activeState.type === 'progress' || + activeState.type === 'apply_status' || + activeState.type === 'apply_progress' || + activeState.type === 'undo_status' || + activeState.type === 'undo_progress' + ) { startPolling(); } } diff --git a/src/webview/pages/DashboardPage.tsx b/src/webview/pages/DashboardPage.tsx index f924f2e..95b98f9 100644 --- a/src/webview/pages/DashboardPage.tsx +++ b/src/webview/pages/DashboardPage.tsx @@ -23,6 +23,9 @@ export const DashboardPage: React.FC = () => { applySuccess, applyChanges, isUndoing, + undoProgress, + undoError, + undoSuccess, settings, isNativeAiUsed, isAiNamingUsed, @@ -206,9 +209,11 @@ export const DashboardPage: React.FC = () => { > {isApplying ? 'Applying changes...' - : applySuccess - ? 'Categorization Applied' - : 'Apply New Categorization'} + : isUndoing + ? 'Undoing changes...' + : applySuccess + ? 'Categorization Applied' + : 'Apply New Categorization'} @@ -226,6 +231,16 @@ export const DashboardPage: React.FC = () => { )} {applyError &&
Error: {applyError}
} + + {isUndoing && ( +
+ Reverting changes: {undoProgress.current} / {undoProgress.total} notes processed... +
+ )} + + {undoSuccess &&
Reverted changes successfully!
} + + {undoError &&
Undo Error: {undoError}
} )} diff --git a/src/webview/pages/EmptyStatePage.tsx b/src/webview/pages/EmptyStatePage.tsx index 7d9b53f..aff4a28 100644 --- a/src/webview/pages/EmptyStatePage.tsx +++ b/src/webview/pages/EmptyStatePage.tsx @@ -7,7 +7,17 @@ import { EmptyState } from '../components/EmptyState'; import { NoticeBanner } from '../components/NoticeBanner'; export const EmptyStatePage: React.FC = () => { - const { isRunning, runPipeline, statusText, progress, isNativeAiUsed } = useAppState(); + const { + isRunning, + runPipeline, + statusText, + progress, + isNativeAiUsed, + isUndoing, + undoProgress, + undoSuccess, + undoError, + } = useAppState(); const [isDismissed, setIsDismissed] = React.useState(false); return ( @@ -21,6 +31,21 @@ export const EmptyStatePage: React.FC = () => { onClose={() => setIsDismissed(true)} /> )} + {!isRunning && isUndoing && ( +
+ Reverting changes: {undoProgress.current} / {undoProgress.total} notes processed... +
+ )} + {!isRunning && undoSuccess && ( +
+ Reverted changes successfully! +
+ )} + {!isRunning && undoError && ( +
+ Undo Error: {undoError} +
+ )} {isRunning ? : } ); diff --git a/test/panel/setupPanel.test.ts b/test/panel/setupPanel.test.ts new file mode 100644 index 0000000..19dba23 --- /dev/null +++ b/test/panel/setupPanel.test.ts @@ -0,0 +1,312 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import joplin from 'api'; +import { setupPanel } from '../../src/panel/setupPanel'; +import { OperationState } from '../../src/settings/registerSettings'; +import { WebviewMessage } from '../../src/types/panel'; + +jest.mock('api', () => ({ + __esModule: true, + default: { + plugins: { + installationDir: jest.fn().mockResolvedValue('/mock/install/dir'), + }, + views: { + panels: { + create: jest.fn().mockResolvedValue('aiCategorise.panel'), + setHtml: jest.fn().mockResolvedValue(undefined), + addScript: jest.fn().mockResolvedValue(undefined), + show: jest.fn().mockResolvedValue(undefined), + onMessage: jest.fn(), + }, + }, + commands: { + execute: jest.fn().mockResolvedValue(undefined), + }, + settings: { + value: jest.fn(), + setValue: jest.fn(), + }, + }, +})); + +jest.mock('../../src/pipeline/runPipeline', () => ({ + runPipeline: jest.fn(), +})); + +jest.mock('../../src/commands/applyChanges', () => ({ + applyCategorizationChanges: jest.fn(), + undoCategorizationChanges: jest.fn(), +})); + +describe('setupPanel & getInitialState persistence', () => { + let messageHandler: (msg: WebviewMessage) => Promise; + let operationState: OperationState; + + beforeEach(async () => { + jest.clearAllMocks(); + operationState = { inProgress: false }; + + (joplin.views.panels.onMessage as jest.Mock).mockImplementation((_panel, handler) => { + messageHandler = handler; + }); + + await setupPanel(operationState); + }); + + it('returns idle state initially on getInitialState', async () => { + const state = await messageHandler({ type: 'getInitialState' }); + expect(state).toEqual({ type: 'idle' }); + }); + + it('preserves results when syncState is called', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k3', + algorithm: 'kmeans', + clusterCount: 3, + assignments: [0, 1, 2], + clusterSizes: [1, 1, 1], + silhouetteScore: 0.8, + outlierCount: 0, + timeMs: 10, + clusterNames: { 0: 'A', 1: 'B', 2: 'C' }, + }, + ], + notes: [ + { noteId: '1', title: 'Note 1' }, + { noteId: '2', title: 'Note 2' }, + { noteId: '3', title: 'Note 3' }, + ], + selectedStrategyIndex: 0, + }); + + const state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.strategies).toHaveLength(1); + expect(state.notes).toHaveLength(3); + expect(state.panelState).toBeUndefined(); + }); + + it('preserves apply_progress and results on getInitialState', async () => { + // Sync results + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k3', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.8, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + // Trigger apply via setPanelState / apply message + operationState.setPanelState!({ type: 'apply_progress', current: 1, total: 5 }); + + const state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.strategies).toHaveLength(1); + expect(state.panelState).toEqual({ type: 'apply_progress', current: 1, total: 5 }); + }); + + it('preserves apply_complete and results on getInitialState', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + operationState.setPanelState!({ type: 'apply_complete' }); + + const state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toEqual({ type: 'apply_complete' }); + }); + + it('preserves undo_progress and undo_complete on getInitialState', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + // Native undo in progress + operationState.setPanelState!({ type: 'undo_progress', current: 2, total: 4 }); + let state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toEqual({ type: 'undo_progress', current: 2, total: 4 }); + + // Native undo complete + operationState.setPanelState!({ type: 'undo_complete' }); + state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toEqual({ type: 'undo_complete' }); + }); + + it('returns undo_complete directly if no prior results exist', async () => { + operationState.setPanelState!({ type: 'undo_complete' }); + const state = await messageHandler({ type: 'getInitialState' }); + expect(state).toEqual({ type: 'undo_complete' }); + }); + + it('preserves apply_error and undo_error on getInitialState', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + operationState.setPanelState!({ type: 'apply_error', message: 'Move failed' }); + let state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toEqual({ type: 'apply_error', message: 'Move failed' }); + + operationState.setPanelState!({ type: 'undo_error', message: 'Revert failed' }); + state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toEqual({ type: 'undo_error', message: 'Revert failed' }); + }); + + it('resets terminal apply_complete state on syncState when mutations occur', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + operationState.setPanelState!({ type: 'apply_complete' }); + let state = await messageHandler({ type: 'getInitialState' }); + expect(state.panelState).toEqual({ type: 'apply_complete' }); + + // User edits a cluster or moves a note -> syncState fires + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + clusterNames: { 0: 'Renamed Cluster' }, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + // panelState should no longer bundle apply_complete + state = await messageHandler({ type: 'getInitialState' }); + expect(state.type).toBe('results'); + expect(state.panelState).toBeUndefined(); + }); + + it('returns pipeline status/progress early on getInitialState during re-run', async () => { + await messageHandler({ + type: 'syncState', + strategies: [ + { + strategyName: 'kmeans_k2', + algorithm: 'kmeans', + clusterCount: 1, + assignments: [0], + clusterSizes: [1], + silhouetteScore: 0.7, + outlierCount: 0, + timeMs: 10, + }, + ], + notes: [{ noteId: '1', title: 'Note 1' }], + selectedStrategyIndex: 0, + }); + + operationState.setPanelState!({ type: 'apply_complete' }); + + // User clicks Run + const runResult = await messageHandler({ type: 'run' }); + expect(runResult.type).toBe('status'); + + // Webview remounts while status is active + let state = await messageHandler({ type: 'getInitialState' }); + expect(state).toEqual({ type: 'status', text: 'Starting pipeline...' }); + + // Pipeline advances to progress + operationState.setPanelState!({ + type: 'progress', + current: 5, + total: 10, + cached: 2, + skipped: 0, + isNativeAiUsed: true, + }); + state = await messageHandler({ type: 'getInitialState' }); + expect(state).toEqual({ + type: 'progress', + current: 5, + total: 10, + cached: 2, + skipped: 0, + isNativeAiUsed: true, + }); + }); +});