一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
540 行
15 KiB
TypeScript
540 行
15 KiB
TypeScript
import { produce } from 'immer';
|
|
import Setting from '@joplin/lib/models/Setting';
|
|
import { defaultState, defaultWindowId, defaultWindowState, State, stateUtils, WindowState } from '@joplin/lib/reducer';
|
|
import iterateItems from './gui/ResizableLayout/utils/iterateItems';
|
|
import { LayoutItem } from './gui/ResizableLayout/utils/types';
|
|
import validateLayout from './gui/ResizableLayout/utils/validateLayout';
|
|
import Logger from '@joplin/utils/Logger';
|
|
import { ChatMessage, ChatRole } from '@joplin/lib/services/ai/types';
|
|
|
|
const logger = Logger.create('app.reducer');
|
|
|
|
export interface AiChatMessage {
|
|
id: string;
|
|
createdTime: number;
|
|
noteId: string;
|
|
noteTitle: string;
|
|
role: 'user' | 'assistant' | 'error' | 'separator';
|
|
text: string;
|
|
hide?: boolean;
|
|
|
|
// The raw message(s) corresponding to this event
|
|
raw: ChatMessage[];
|
|
}
|
|
|
|
// Joplin Cloud degradation / budget snapshot. Populated from the provider's
|
|
// InternalChatResult after each chat() call, and persisted to Setting so it
|
|
// survives restarts and reflects plugin-driven calls even when no UI was open.
|
|
export interface AiStatus {
|
|
degraded: boolean;
|
|
tokensUsed: number;
|
|
tokensBudget: number;
|
|
lastToastShownAt: number | null;
|
|
}
|
|
|
|
export const defaultAiStatus = (): AiStatus => ({
|
|
degraded: false,
|
|
tokensUsed: 0,
|
|
tokensBudget: 0,
|
|
lastToastShownAt: null,
|
|
});
|
|
|
|
export interface AppStateRoute {
|
|
type: string;
|
|
routeName: string;
|
|
props: Record<string, unknown>;
|
|
}
|
|
|
|
export enum AppStateDialogName {
|
|
SyncWizard = 'syncWizard',
|
|
MasterPassword = 'masterPassword',
|
|
}
|
|
|
|
export interface AppStateDialog {
|
|
name: AppStateDialogName;
|
|
props: Record<string, unknown>;
|
|
}
|
|
|
|
export interface NoteIdToScrollPercent {
|
|
[noteId: string]: number;
|
|
}
|
|
|
|
export interface VisibleDialogs {
|
|
[dialogKey: string]: boolean;
|
|
}
|
|
|
|
export interface AppWindowState extends WindowState {
|
|
noteVisiblePanes: string[];
|
|
editorCodeView: boolean;
|
|
visibleDialogs: VisibleDialogs;
|
|
dialogs: AppStateDialog[];
|
|
devToolsVisible: boolean;
|
|
watchedResources: Record<string, unknown>;
|
|
// Note IDs for which the user has chosen to view the underlying Markdown
|
|
// instead of the Whiteboard editor. Per-window, in-memory only.
|
|
whiteboardForceMarkdown: Record<string, boolean>;
|
|
// Whether the currently-active note in this window contains a whiteboard
|
|
// fence. Set by the NoteEditor when it loads / saves the body, used by
|
|
// the toolbar to show the editor toggle button. (We can't compute this
|
|
// from the redux note list because `body` isn't in the preview fields.)
|
|
activeNoteIsWhiteboard: boolean;
|
|
// In window state so the conversation survives panel hide/show (the
|
|
// layout container can swap component types and unmount the panel).
|
|
aiChatMessages: AiChatMessage[];
|
|
aiChatConversationId: string|null;
|
|
// Layout for secondary windows
|
|
secondaryWindowLayout: LayoutItem|null;
|
|
}
|
|
|
|
interface BackgroundWindowStates {
|
|
[windowId: string]: AppWindowState;
|
|
}
|
|
|
|
export interface AppState extends State, AppWindowState {
|
|
backgroundWindows: BackgroundWindowStates;
|
|
|
|
route: AppStateRoute;
|
|
navHistory: AppStateRoute[];
|
|
watchedNoteFiles: string[];
|
|
focusedField: string;
|
|
layoutMoveMode: boolean;
|
|
startupPluginsLoaded: boolean;
|
|
modalOverlayMessage: string|null;
|
|
modalOverlayHasCloseButton: boolean;
|
|
|
|
// Extra reducer keys go here
|
|
mainLayout: LayoutItem;
|
|
isResettingLayout: boolean;
|
|
aiStatus: AiStatus;
|
|
}
|
|
|
|
export const createAppDefaultWindowState = (): AppWindowState => {
|
|
return {
|
|
...defaultWindowState,
|
|
visibleDialogs: {},
|
|
dialogs: [],
|
|
noteVisiblePanes: ['editor', 'viewer'],
|
|
editorCodeView: true,
|
|
devToolsVisible: false,
|
|
watchedResources: {},
|
|
whiteboardForceMarkdown: {},
|
|
activeNoteIsWhiteboard: false,
|
|
aiChatMessages: [],
|
|
aiChatConversationId: null,
|
|
secondaryWindowLayout: null,
|
|
};
|
|
};
|
|
|
|
export function createAppDefaultState(resourceEditWatcherDefaultState: Partial<AppState>): AppState {
|
|
return {
|
|
...defaultState,
|
|
...createAppDefaultWindowState(),
|
|
backgroundWindows: {},
|
|
route: {
|
|
type: 'NAV_GO',
|
|
routeName: 'Main',
|
|
props: {},
|
|
},
|
|
navHistory: [],
|
|
watchedNoteFiles: [],
|
|
visibleDialogs: {}, // empty object if no dialog is visible. Otherwise contains the list of visible dialogs.
|
|
focusedField: null,
|
|
layoutMoveMode: false,
|
|
mainLayout: null,
|
|
startupPluginsLoaded: false,
|
|
isResettingLayout: false,
|
|
modalOverlayMessage: null,
|
|
modalOverlayHasCloseButton: false,
|
|
aiStatus: defaultAiStatus(),
|
|
...resourceEditWatcherDefaultState,
|
|
};
|
|
}
|
|
|
|
const hideBackgroundDialogsWithId = produce((state: AppState, id: string) => {
|
|
for (const windowId of Object.keys(state.backgroundWindows)) {
|
|
const win = state.backgroundWindows[windowId];
|
|
if (id in win.visibleDialogs) {
|
|
delete win.visibleDialogs[id];
|
|
}
|
|
}
|
|
});
|
|
|
|
const withWindowStateUpdated = <Key extends keyof AppWindowState> (
|
|
state: AppState, windowId: string, stateKey: Key, value: (oldValue: AppWindowState[Key])=> AppWindowState[Key],
|
|
) => {
|
|
return produce((state: AppState) => {
|
|
const windowState = stateUtils.windowStateById(state, windowId);
|
|
windowState[stateKey] = value(windowState[stateKey]);
|
|
})(state);
|
|
};
|
|
|
|
interface SetLayoutPropOptions {
|
|
key: string;
|
|
prop: string;
|
|
value: string;
|
|
}
|
|
|
|
const setLayoutProp = (layout: LayoutItem, { key, prop, value }: SetLayoutPropOptions) => {
|
|
let newLayout = produce(layout, (draftLayout: LayoutItem) => {
|
|
iterateItems(draftLayout, (_itemIndex: number, item: LayoutItem, _parent: LayoutItem) => {
|
|
if (!item) {
|
|
logger.warn('MAIN_LAYOUT_SET_ITEM_PROP: Found an empty item in layout: ', JSON.stringify(layout));
|
|
} else {
|
|
if (item.key === key) {
|
|
(item as unknown as Record<string, unknown>)[prop] = value;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
});
|
|
|
|
if (newLayout !== layout) newLayout = validateLayout(newLayout);
|
|
|
|
return newLayout;
|
|
};
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Redux actions are heterogeneous; typing this would require an action-type union and many narrowing casts inside the switch
|
|
export default function(state: AppState, action: any) {
|
|
let newState = state;
|
|
|
|
try {
|
|
switch (action.type) {
|
|
|
|
case 'NAV_BACK':
|
|
case 'NAV_GO':
|
|
|
|
{
|
|
const goingBack = action.type === 'NAV_BACK';
|
|
|
|
if (goingBack && !state.navHistory.length) break;
|
|
|
|
const currentRoute = state.route;
|
|
|
|
newState = { ...state };
|
|
const newNavHistory = state.navHistory.slice();
|
|
|
|
if (goingBack) {
|
|
let newAction = null;
|
|
while (newNavHistory.length) {
|
|
newAction = newNavHistory.pop();
|
|
if (newAction.routeName !== state.route.routeName) break;
|
|
}
|
|
|
|
if (!newAction) break;
|
|
|
|
action = newAction;
|
|
}
|
|
|
|
if (!goingBack) newNavHistory.push(currentRoute);
|
|
newState.navHistory = newNavHistory;
|
|
newState.route = action;
|
|
}
|
|
break;
|
|
|
|
case 'STARTUP_PLUGINS_LOADED':
|
|
|
|
// When all startup plugins have loaded, we also recreate the
|
|
// main layout to ensure that it is updated in the UI. There's
|
|
// probably a cleaner way to do this, but for now that will do.
|
|
if (state.startupPluginsLoaded !== action.value) {
|
|
newState = {
|
|
...newState,
|
|
startupPluginsLoaded: action.value,
|
|
mainLayout: JSON.parse(JSON.stringify(newState.mainLayout)),
|
|
};
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_VISIBLE_PANES_TOGGLE':
|
|
|
|
{
|
|
const getNextLayout = (currentLayout: string | string[]) => {
|
|
currentLayout = panes.length === 2 ? 'both' : currentLayout[0];
|
|
|
|
let paneOptions;
|
|
if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_VIEWER) {
|
|
paneOptions = ['editor', 'viewer'];
|
|
} else if (state.settings.layoutButtonSequence === Setting.LAYOUT_EDITOR_SPLIT) {
|
|
paneOptions = ['editor', 'both'];
|
|
} else if (state.settings.layoutButtonSequence === Setting.LAYOUT_VIEWER_SPLIT) {
|
|
paneOptions = ['viewer', 'both'];
|
|
} else {
|
|
paneOptions = ['editor', 'viewer', 'both'];
|
|
}
|
|
|
|
const currentLayoutIndex = paneOptions.indexOf(currentLayout);
|
|
const nextLayoutIndex = currentLayoutIndex === paneOptions.length - 1 ? 0 : currentLayoutIndex + 1;
|
|
|
|
const nextLayout = paneOptions[nextLayoutIndex];
|
|
return nextLayout === 'both' ? ['editor', 'viewer'] : [nextLayout];
|
|
};
|
|
|
|
newState = { ...state };
|
|
|
|
const panes = state.noteVisiblePanes.slice();
|
|
newState.noteVisiblePanes = getNextLayout(panes);
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_VISIBLE_PANES_SET':
|
|
newState = {
|
|
...state,
|
|
noteVisiblePanes: action.panes,
|
|
};
|
|
break;
|
|
|
|
case 'EDITOR_CODE_VIEW_CHANGE':
|
|
newState = {
|
|
...state,
|
|
editorCodeView: action.value,
|
|
};
|
|
break;
|
|
|
|
case 'WHITEBOARD_FORCE_MARKDOWN_TOGGLE': {
|
|
const id: unknown = action.noteId;
|
|
// Guard against dispatchers forgetting to pass a noteId — writing
|
|
// an `undefined` key into the map would persist a junk entry.
|
|
if (typeof id !== 'string' || !id) break;
|
|
const current = !!state.whiteboardForceMarkdown?.[id];
|
|
newState = {
|
|
...state,
|
|
whiteboardForceMarkdown: { ...(state.whiteboardForceMarkdown || {}), [id]: !current },
|
|
};
|
|
break;
|
|
}
|
|
|
|
case 'WHITEBOARD_ACTIVE_NOTE_SET':
|
|
newState = withWindowStateUpdated(
|
|
state, action.windowId, 'activeNoteIsWhiteboard', () => !!action.value,
|
|
);
|
|
break;
|
|
|
|
case 'AI_CHAT_DELETE':
|
|
newState = produce(state, draft => {
|
|
for (const windowState of stateUtils.allWindowStates(draft)) {
|
|
if (windowState.aiChatConversationId !== action.conversationId) continue;
|
|
windowState.aiChatConversationId = null;
|
|
windowState.aiChatMessages = [];
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'AI_CHAT_OPEN':
|
|
newState = produce(state, draft => {
|
|
const openConversation = action.conversationId && stateUtils.allWindowStates(state)
|
|
.find(windowState => windowState.aiChatConversationId === action.conversationId);
|
|
const windowState = stateUtils.windowStateById(draft, action.windowId);
|
|
windowState.aiChatConversationId = action.conversationId;
|
|
windowState.aiChatMessages = openConversation ? openConversation.aiChatMessages : action.messages;
|
|
});
|
|
break;
|
|
|
|
case 'AI_CHAT_APPEND':
|
|
case 'AI_CHAT_ADD_TOOL_RESULT':
|
|
case 'AI_CHAT_REMOVE':
|
|
newState = produce(state, draft => {
|
|
for (const windowState of stateUtils.allWindowStates(draft)) {
|
|
if (windowState.aiChatConversationId !== action.conversationId) continue;
|
|
if (action.type === 'AI_CHAT_APPEND') {
|
|
windowState.aiChatMessages.push(action.message);
|
|
} else if (action.type === 'AI_CHAT_REMOVE') {
|
|
windowState.aiChatMessages = windowState.aiChatMessages.filter(message => message.id !== action.id);
|
|
} else {
|
|
const message = windowState.aiChatMessages.find(message => message.raw.some(entry =>
|
|
entry.role === ChatRole.Assistant && entry.toolCalls?.some(call => call.callId === action.toolCall.toolCallId),
|
|
));
|
|
message?.raw.push(action.toolCall);
|
|
}
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'AI_STATUS_UPDATE':
|
|
// Partial merge — callers can bump `lastToastShownAt` alone after
|
|
// firing the toast without clobbering the degraded/usage numbers.
|
|
newState = {
|
|
...state,
|
|
aiStatus: {
|
|
...(state.aiStatus ?? defaultAiStatus()),
|
|
...(action.payload as Partial<AiStatus>),
|
|
},
|
|
};
|
|
break;
|
|
|
|
case 'WINDOW_LAYOUT_SET':
|
|
case 'MAIN_LAYOUT_SET':
|
|
if ((action.windowId ?? defaultWindowId) === defaultWindowId) {
|
|
newState = {
|
|
...state,
|
|
mainLayout: action.value,
|
|
};
|
|
} else {
|
|
newState = withWindowStateUpdated(
|
|
state, action.windowId, 'secondaryWindowLayout', () => action.value,
|
|
);
|
|
}
|
|
break;
|
|
|
|
case 'WINDOW_LAYOUT_SET_ITEM_PROP':
|
|
case 'MAIN_LAYOUT_SET_ITEM_PROP': {
|
|
const updateOption = { key: action.itemKey, prop: action.propName, value: action.propValue };
|
|
|
|
if ((action.windowId ?? defaultWindowId) !== defaultWindowId) {
|
|
newState = withWindowStateUpdated(
|
|
state, action.windowId, 'secondaryWindowLayout', oldLayout => (
|
|
setLayoutProp(oldLayout, updateOption)
|
|
),
|
|
);
|
|
} else {
|
|
newState = {
|
|
...state,
|
|
mainLayout: setLayoutProp(state.mainLayout, updateOption),
|
|
};
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'SHOW_MODAL_MESSAGE':
|
|
newState = {
|
|
...newState,
|
|
modalOverlayMessage: action.message,
|
|
modalOverlayHasCloseButton: !!action.hasCloseButton,
|
|
};
|
|
break;
|
|
|
|
case 'HIDE_MODAL_MESSAGE':
|
|
newState = { ...newState, modalOverlayMessage: null, modalOverlayHasCloseButton: false };
|
|
break;
|
|
|
|
case 'NOTE_FILE_WATCHER_ADD':
|
|
|
|
if (newState.watchedNoteFiles.indexOf(action.id) < 0) {
|
|
newState = { ...state };
|
|
const watchedNoteFiles = newState.watchedNoteFiles.slice();
|
|
watchedNoteFiles.push(action.id);
|
|
newState.watchedNoteFiles = watchedNoteFiles;
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_FILE_WATCHER_REMOVE':
|
|
|
|
{
|
|
newState = { ...state };
|
|
const idx = newState.watchedNoteFiles.indexOf(action.id);
|
|
if (idx >= 0) {
|
|
const watchedNoteFiles = newState.watchedNoteFiles.slice();
|
|
watchedNoteFiles.splice(idx, 1);
|
|
newState.watchedNoteFiles = watchedNoteFiles;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_FILE_WATCHER_CLEAR':
|
|
|
|
if (state.watchedNoteFiles.length) {
|
|
newState = { ...state };
|
|
newState.watchedNoteFiles = [];
|
|
}
|
|
break;
|
|
|
|
case 'NOTE_DEVTOOLS_TOGGLE':
|
|
newState = { ...state };
|
|
newState.devToolsVisible = !newState.devToolsVisible;
|
|
break;
|
|
|
|
case 'NOTE_DEVTOOLS_SET':
|
|
newState = { ...state };
|
|
newState.devToolsVisible = action.value;
|
|
break;
|
|
|
|
case 'VISIBLE_DIALOGS_ADD':
|
|
newState = { ...state };
|
|
newState.visibleDialogs = { ...newState.visibleDialogs };
|
|
newState.visibleDialogs[action.name] = true;
|
|
newState = hideBackgroundDialogsWithId(newState, action.name);
|
|
break;
|
|
|
|
case 'VISIBLE_DIALOGS_REMOVE':
|
|
newState = { ...state };
|
|
newState.visibleDialogs = { ...newState.visibleDialogs };
|
|
delete newState.visibleDialogs[action.name];
|
|
newState = hideBackgroundDialogsWithId(newState, action.name);
|
|
break;
|
|
|
|
case 'FOCUS_SET':
|
|
|
|
newState = { ...state };
|
|
newState.focusedField = action.field;
|
|
break;
|
|
|
|
case 'FOCUS_CLEAR':
|
|
|
|
// A field can only clear its own state
|
|
if (action.field === state.focusedField) {
|
|
newState = { ...state };
|
|
newState.focusedField = null;
|
|
}
|
|
break;
|
|
|
|
case 'DIALOG_OPEN':
|
|
case 'DIALOG_CLOSE':
|
|
|
|
{
|
|
let isOpen = true;
|
|
|
|
if (action.type === 'DIALOG_CLOSE') {
|
|
isOpen = false;
|
|
} else { // DIALOG_OPEN
|
|
isOpen = action.isOpen !== false;
|
|
}
|
|
|
|
newState = { ...state };
|
|
|
|
if (isOpen) {
|
|
const newDialogs = newState.dialogs.slice();
|
|
|
|
if (newDialogs.find(d => d.name === action.name)) throw new Error(`Trying to open a dialog is already open: ${action.name}`);
|
|
|
|
newDialogs.push({
|
|
name: action.name,
|
|
props: action.props || {},
|
|
});
|
|
|
|
newState.dialogs = newDialogs;
|
|
} else {
|
|
if (!newState.dialogs.find(d => d.name === action.name)) throw new Error(`Trying to close a dialog that is not open: ${action.name}`);
|
|
const newDialogs = newState.dialogs.slice().filter(d => d.name !== action.name);
|
|
newState.dialogs = newDialogs;
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'LAYOUT_MOVE_MODE_SET':
|
|
|
|
newState = {
|
|
...state,
|
|
layoutMoveMode: action.value,
|
|
};
|
|
break;
|
|
|
|
|
|
case 'RESET_LAYOUT':
|
|
newState = {
|
|
...state,
|
|
isResettingLayout: action.value,
|
|
};
|
|
break;
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
|
throw error;
|
|
}
|
|
|
|
return newState;
|
|
}
|