diff --git a/packages/marqus-desktop/src/main/index.ts b/packages/marqus-desktop/src/main/index.ts index 19ebcb26..e510e806 100644 --- a/packages/marqus-desktop/src/main/index.ts +++ b/packages/marqus-desktop/src/main/index.ts @@ -129,8 +129,7 @@ export async function main(): Promise { app.on("ready", createWindow); } } catch (err) { - logger.error("Error: Failed to initialize app."); - logger.error(err); + logger.error("Error: Failed to initialize app.", err); mainWindow.webContents.openDevTools(); } diff --git a/packages/marqus-desktop/src/main/schemas/appState/4_addIsPreview.ts b/packages/marqus-desktop/src/main/schemas/appState/4_addIsPreview.ts new file mode 100644 index 00000000..a1783eda --- /dev/null +++ b/packages/marqus-desktop/src/main/schemas/appState/4_addIsPreview.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; +import { ModelViewState, Section } from "../../../shared/ui/app"; +import { DATE_OR_STRING_SCHEMA, PX_REGEX } from "../../../shared/domain"; +import { NoteSort } from "../../../shared/domain/note"; +import { AppStateV3 } from "./3_addIsPinned"; + +export interface AppStateV4 { + version: number; + sidebar: Sidebar; + editor: Editor; + focused: Section[]; +} + +interface Sidebar { + searchString?: string; + hidden?: boolean; + width: string; + scroll: number; + selected?: string[]; + expanded?: string[]; + sort: NoteSort; +} + +interface Editor { + isEditing: boolean; + scroll: number; + tabs: EditorTab[]; + tabsScroll: number; + activeTabNoteId?: string; +} + +interface EditorTab { + noteId: string; + noteContent?: string; + lastActive?: Date | string; + viewState?: ModelViewState["viewState"]; + isPinned?: boolean; + isPreview?: boolean; +} + +export const appStateV4 = z.preprocess( + obj => { + const state = obj as AppStateV3 | AppStateV4; + if (state.version === 3) { + state.version = 4; + } + + return state; + }, + z.object({ + version: z.literal(4), + sidebar: z.object({ + width: z.string().regex(PX_REGEX), + scroll: z.number(), + hidden: z.boolean().optional(), + selected: z.array(z.string()).optional(), + expanded: z.array(z.string()).optional(), + sort: z.nativeEnum(NoteSort), + searchString: z.string().optional(), + }), + editor: z.object({ + isEditing: z.boolean(), + scroll: z.number(), + tabs: z.array( + z.object({ + noteId: z.string(), + // Intentionally omitted noteContent + lastActive: DATE_OR_STRING_SCHEMA.optional(), + viewState: z.any().optional(), + isPinned: z.boolean().optional(), + isPreview: z.boolean().optional(), + }), + ), + tabsScroll: z.number(), + activeTabNoteId: z.string().optional(), + }), + focused: z.array(z.nativeEnum(Section)), + }), +); diff --git a/packages/marqus-desktop/src/main/schemas/appState/index.ts b/packages/marqus-desktop/src/main/schemas/appState/index.ts index 9f60f96e..f6995372 100644 --- a/packages/marqus-desktop/src/main/schemas/appState/index.ts +++ b/packages/marqus-desktop/src/main/schemas/appState/index.ts @@ -1,9 +1,11 @@ import { appStateV1 } from "./1_initialDefinition"; import { appStateV2 } from "./2_addViewState"; import { appStateV3 } from "./3_addIsPinned"; +import { appStateV4 } from "./4_addIsPreview"; export const APP_STATE_SCHEMAS = { 1: appStateV1, 2: appStateV2, 3: appStateV3, + 4: appStateV4, }; diff --git a/packages/marqus-desktop/src/renderer/App.tsx b/packages/marqus-desktop/src/renderer/App.tsx index 5b74d57b..891c982f 100644 --- a/packages/marqus-desktop/src/renderer/App.tsx +++ b/packages/marqus-desktop/src/renderer/App.tsx @@ -162,6 +162,7 @@ export async function loadInitialState( note: getNoteById(notes, t.noteId, false), lastActive: t.lastActive, isPinned: t.isPinned, + isPreview: t.isPreview, })) .filter(t => t.note != null) as EditorTab[]; diff --git a/packages/marqus-desktop/src/renderer/components/Editor.tsx b/packages/marqus-desktop/src/renderer/components/Editor.tsx index b84675f1..202e4f02 100644 --- a/packages/marqus-desktop/src/renderer/components/Editor.tsx +++ b/packages/marqus-desktop/src/renderer/components/Editor.tsx @@ -103,6 +103,9 @@ const setContent: Listener<"editor.setContent"> = async ({ value }, ctx) => { if (prev.editor.tabs[index].isNewNote) { delete prev.editor.tabs[index].isNewNote; } + if (prev.editor.tabs[index].isPreview) { + delete prev.editor.tabs[index].isPreview; + } return { editor: { diff --git a/packages/marqus-desktop/src/renderer/components/EditorTab.tsx b/packages/marqus-desktop/src/renderer/components/EditorTab.tsx index 45b6197c..9d452319 100644 --- a/packages/marqus-desktop/src/renderer/components/EditorTab.tsx +++ b/packages/marqus-desktop/src/renderer/components/EditorTab.tsx @@ -29,6 +29,7 @@ export interface EditorTabProps { noteName: string; active?: boolean; isPinned?: boolean; + isPreview?: boolean; onClick: (noteId: string) => void; onClose: (noteId: string) => void; onUnpin: (noteId: string) => void; @@ -109,7 +110,7 @@ export function EditorTab(props: EditorTabProps): JSX.Element { - {noteName} + {noteName} , @@ -148,7 +149,7 @@ export function EditorTab(props: EditorTabProps): JSX.Element { - {noteName} + {noteName} {action} @@ -209,13 +210,15 @@ const StyledTab = styled.a<{ active?: boolean }>` } `; -const StyledText = styled.span` +const StyledText = styled.span<{ isPreview?: boolean }>` font-size: 1.2rem; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; min-width: 0; + + font-style: ${p => (p.isPreview ? "italic" : "normal")}; `; const StyledDeleteIcon = styled(Icon)` diff --git a/packages/marqus-desktop/src/renderer/components/EditorToolbar.tsx b/packages/marqus-desktop/src/renderer/components/EditorToolbar.tsx index 18433e3d..39bfa000 100644 --- a/packages/marqus-desktop/src/renderer/components/EditorToolbar.tsx +++ b/packages/marqus-desktop/src/renderer/components/EditorToolbar.tsx @@ -105,6 +105,7 @@ export function EditorToolbar(props: EditorToolbarProps): JSX.Element { notePath={notePath} active={activeTabNoteId === note.id} isPinned={tab.isPinned} + isPreview={tab.isPreview} onClick={onClick} onClose={onClose} onUnpin={onUnpin} @@ -231,10 +232,15 @@ export function EditorToolbar(props: EditorToolbarProps): JSX.Element { } ctx.setCache(prev => { - const newlyClosedTabs: ClosedEditorTab[] = noteIdsToClose.map(noteId => ({ - noteId, - previousIndex: editor.tabs.findIndex(t => t.note.id === noteId), - })); + const newlyClosedTabs: ClosedEditorTab[] = noteIdsToClose.map(noteId => { + const previousIndex = editor.tabs.findIndex(t => t.note.id === noteId); + + return { + noteId, + previousIndex, + isPreview: editor.tabs[previousIndex].isPreview, + }; + }); const closedTabs = [...newlyClosedTabs, ...prev.closedTabs]; @@ -449,12 +455,11 @@ const TabsScrollable = styled(Scrollable)` export const openTab: Listener<"editor.openTab"> = async (ev, ctx) => { // Keep in sync with sidebar.openSelectedNotes listener - const { editor, notes } = ctx.getState(); - if (ev.value?.note == null) { return; } + const { notes } = ctx.getState(); const notesToOpen = arrayify(ev.value.note); const noteIds: string[] = []; let activeTabNoteId: string | undefined = undefined; @@ -481,30 +486,7 @@ export const openTab: Listener<"editor.openTab"> = async (ev, ctx) => { return; } - const tabs = [...editor.tabs]; - - for (const noteId of noteIds) { - let newTab = false; - let tab = editor.tabs.find(t => t.note.id === noteId); - - if (tab == null) { - newTab = true; - const note = getNoteById(notes, noteId); - tab = { note }; - } - - tab.lastActive = new Date(); - - if (newTab) { - tabs.push(tab); - } - } - - ctx.setUI({ - editor: { - tabs, - }, - }); + openTabsForNotes(ctx, noteIds); setActiveTab(ctx, activeTabNoteId); @@ -524,7 +506,7 @@ export const reopenClosedTab: Listener<"editor.reopenClosedTab"> = async ( return; } - const { noteId, previousIndex } = closedTabs[0]; + const { noteId, previousIndex, isPreview } = closedTabs[0]; ctx.setCache(prev => { const closedTabs = prev.closedTabs.slice(1); @@ -545,7 +527,7 @@ export const reopenClosedTab: Listener<"editor.reopenClosedTab"> = async ( const { tabs } = prev.editor; const newIndex = Math.min(previousIndex, tabs.length); - tabs.splice(newIndex, 0, { note }); + tabs.splice(newIndex, 0, { note, isPreview }); return { editor: { @@ -683,6 +665,56 @@ export const moveTab: Listener<"editor.moveTab"> = async ({ value }, ctx) => { }); }; +export function openTabsForNotes(ctx: StoreContext, noteIds: string[]): void { + if (noteIds.length === 0) { + return; + } + + const { editor, notes } = ctx.getState(); + let tabs = [...editor.tabs]; + + for (const noteId of noteIds) { + let newTab = false; + let tab = editor.tabs.find(t => t.note.id === noteId); + + if (tab == null) { + newTab = true; + const note = getNoteById(notes, noteId); + tab = { note }; + + // Only open tabs in preview mode if we opened a single tab. + if (noteIds.length === 1) { + tab.isPreview = true; + + // Only one preview tab can be open at once. + tabs = tabs.filter(t => !t.isPreview); + } + } + // Second open of a tab takes it out of preview mode. + else if (tab.isPreview) { + delete tab.isPreview; + } + + tab.lastActive = new Date(); + + if (newTab) { + tabs.push(tab); + } + } + + let { activeTabNoteId } = editor; + if (!activeTabNoteId) { + activeTabNoteId = tabs[0].note.id; + } + + ctx.setUI({ + editor: { + tabs, + activeTabNoteId, + }, + }); +} + export function setActiveTab( ctx: StoreContext, activeTabNoteId: string | undefined, diff --git a/packages/marqus-desktop/src/renderer/components/Sidebar.tsx b/packages/marqus-desktop/src/renderer/components/Sidebar.tsx index 6acd0eb3..94a60441 100644 --- a/packages/marqus-desktop/src/renderer/components/Sidebar.tsx +++ b/packages/marqus-desktop/src/renderer/components/Sidebar.tsx @@ -24,11 +24,10 @@ import { faChevronRight, } from "@fortawesome/free-solid-svg-icons"; import { SidebarSearch } from "./SidebarSearch"; -import { EditorTab } from "../../shared/ui/app"; import { SidebarNewNoteButton } from "./SidebarNewNoteButton"; import { Section } from "../../shared/ui/app"; import { deleteNoteIfConfirmed } from "../utils/deleteNoteIfConfirmed"; -import { cleanupClosedTabsCache } from "./EditorToolbar"; +import { cleanupClosedTabsCache, openTabsForNotes } from "./EditorToolbar"; const EXPANDED_ICON = faChevronDown; const COLLAPSED_ICON = faChevronRight; @@ -723,47 +722,20 @@ export const openSelectedNotes: Listener<"sidebar.openSelectedNotes"> = async ( ctx, ) => { // Keep in sync with editor.openTab listener - const { sidebar, editor, notes } = ctx.getState(); - const { selected } = sidebar; + const { + sidebar: { selected }, + } = ctx.getState(); if (selected == null || selected.length === 0) { return; } - const notesToOpen = selected.map(s => getNoteById(notes, s)); - const tabs = [...editor.tabs]; - - let firstTab: EditorTab | undefined; - for (const note of notesToOpen) { - let newTab = false; - let tab = editor.tabs.find(t => t.note.id === note.id); - - if (tab == null) { - newTab = true; - tab = { note }; - } - - tab.lastActive = new Date(); - - if (newTab) { - tabs.push(tab); - } - - if (firstTab == null) { - firstTab = tab; - } - } + openTabsForNotes(ctx, selected); // Editor is not set as focused when a note is opened from the sidebar because // the user may not want to start editing the note yet. This makes it easier // to delete a note because otherwise each time they clicked on a note, they'd // have to click back into the editor and then hit delete. - ctx.setUI(prev => ({ - editor: { - tabs, - activeTabNoteId: firstTab?.note.id ?? prev.editor.activeTabNoteId, - }, - })); cleanupClosedTabsCache(ctx); }; diff --git a/packages/marqus-desktop/src/renderer/store.ts b/packages/marqus-desktop/src/renderer/store.ts index 7c2caf29..d9d9b15f 100644 --- a/packages/marqus-desktop/src/renderer/store.ts +++ b/packages/marqus-desktop/src/renderer/store.ts @@ -78,12 +78,16 @@ export type ListenerLookup = { }; export function useStore(initialState: State, initialCache?: Cache): Store { + // We manage a state variable and ref because we need the store to trigger + // re-renders on components that use it's state, but we also need to be able + // to keep state fresh while performing async actions. const [state, setState] = useState(initialState); + const stateRef = useRef(state); + const cache = useRef( initialCache ?? { modelViewStates: {}, closedTabs: [] }, ); const listeners = useRef({}); - const lastState = useRef(state); // Cache is good for storing state that shouldn't trigger a re-render const setCache: SetCache = useCallback(transformer => { @@ -121,8 +125,8 @@ export function useStore(initialState: State, initialCache?: Cache): Store { serializeAppState(newUI, cache.current), ); - lastState.current = { - ...lastState.current, + stateRef.current = { + ...stateRef.current, ...newUI, }; @@ -139,7 +143,7 @@ export function useStore(initialState: State, initialCache?: Cache): Store { setState(prevState => { const shortcuts = transformer(prevState.shortcuts); - lastState.current.shortcuts = shortcuts; + stateRef.current.shortcuts = shortcuts; return { ...prevState, @@ -151,7 +155,7 @@ export function useStore(initialState: State, initialCache?: Cache): Store { setState(prevState => { const notes = transformer(prevState.notes); - lastState.current.notes = notes; + stateRef.current.notes = notes; return { ...prevState, @@ -167,7 +171,7 @@ export function useStore(initialState: State, initialCache?: Cache): Store { } // Don't push new section if new first is the same. - const { current: state } = lastState; + const { current: state } = stateRef; if (!isEmpty(state.focused) && state.focused[0] === sections[0]) { return; } @@ -190,7 +194,7 @@ export function useStore(initialState: State, initialCache?: Cache): Store { setShortcuts, setNotes, focus, - getState: () => cloneDeep(lastState.current), + getState: () => cloneDeep(stateRef.current), getCache: () => cloneDeep(cache.current), }; diff --git a/packages/marqus-desktop/src/shared/ui/app.ts b/packages/marqus-desktop/src/shared/ui/app.ts index ce53a43c..23ef352c 100644 --- a/packages/marqus-desktop/src/shared/ui/app.ts +++ b/packages/marqus-desktop/src/shared/ui/app.ts @@ -47,6 +47,7 @@ export interface EditorTab { lastActive?: Date; isNewNote?: boolean; isPinned?: boolean; + isPreview?: boolean; } export interface Cache { @@ -54,13 +55,17 @@ export interface Cache { closedTabs: ClosedEditorTab[]; } -export type ClosedEditorTab = { noteId: string; previousIndex: number }; - export type ModelViewState = { model?: monaco.editor.ITextModel; viewState?: monaco.editor.ICodeEditorViewState; }; +export type ClosedEditorTab = { + noteId: string; + previousIndex: number; + isPreview?: boolean; +}; + // If a note was deleted but was referenced elsewhere in the ui state we need to // clear out all references to it otherwise things will bork. export function filterOutStaleNoteIds( @@ -129,6 +134,7 @@ export interface SerializedEditorTab { lastActive?: Date; viewState?: monaco.editor.ICodeEditorViewState; isPinned?: boolean; + isPreview?: boolean; } export function serializeAppState( @@ -152,6 +158,7 @@ export function serializeAppState( lastActive: t.lastActive, viewState: cache?.modelViewStates[t.note.id]?.viewState, isPinned: t.isPinned, + isPreview: t.isPreview, })), }, }; diff --git a/packages/marqus-desktop/test/renderer/components/EditorToolbar.spec.tsx b/packages/marqus-desktop/test/renderer/components/EditorToolbar.spec.tsx index cbc71f0f..b2404f12 100644 --- a/packages/marqus-desktop/test/renderer/components/EditorToolbar.spec.tsx +++ b/packages/marqus-desktop/test/renderer/components/EditorToolbar.spec.tsx @@ -94,7 +94,7 @@ test("editor.openTab works with note paths too", async () => { }); ({ editor } = store.current.state); - expect(editor.tabs[1]!.note.id).toBe("4"); + expect(editor.tabs[0]!.note.id).toBe("4"); expect(editor.activeTabNoteId).toBe("4"); });