Something went wrong. Try again.
[mirror] A local-first desktop markdown writer with grammar checking, at proto integration, and more github.com/stormlightlabs/writer/releases
atproto codemirror markdown tauri rust react
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496/**/import { WorkspacePanel } from "$components/AppLayout/WorkspacePanel";import type { WorkspaceDiagnosticsProps, WorkspacePanelProps } from "$components/AppLayout/WorkspacePanel";import { EditorProps } from "$components/Editor";import { PreviewProps } from "$components/Preview";import { StatusBarProps } from "$components/StatusBar";import { useSidebarActions } from "$hooks/controllers/useSidebarActions";import { useWorkspaceController } from "$hooks/controllers/useWorkspaceController";import { useEditorPresentationActions, useEditorPresentationState, useLayoutChromeActions, useLayoutChromeState, useLayoutSettingsUiState, useSidebarState, useTabsState, useToolbarState, useWorkspacePanelModeState, useWorkspacePanelSidebarState, useWorkspacePanelStatusBarCollapsed, useWorkspacePanelTopBarsCollapsed,} from "$state/selectors";import type { EditorPresentationStateReturn, SidebarStateReturn, StatusBarCollapsedReturn, ToolbarStateReturn, TopBarsCollapsedReturn, WorkspacePanelModeStateReturn, WorkspacePanelSidebarStateReturn,} from "$state/selectors";import type { MarkdownPreviewStyle } from "$types";import { act, fireEvent, render, screen } from "@testing-library/react";import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock( "$state/selectors", () => ({ useSidebarState: vi.fn(), useTabsState: vi.fn(), useToolbarState: vi.fn(), useEditorPresentationActions: vi.fn(), useLayoutChromeActions: vi.fn(), useLayoutChromeState: vi.fn(), useLayoutSettingsUiState: vi.fn(), useEditorPresentationState: vi.fn(), useWorkspacePanelSidebarState: vi.fn(), useWorkspacePanelModeState: vi.fn(), useWorkspacePanelTopBarsCollapsed: vi.fn(), useWorkspacePanelStatusBarCollapsed: vi.fn(), }),);vi.mock("$hooks/controllers/useSidebarActions", () => ({ useSidebarActions: vi.fn() }));vi.mock("$hooks/controllers/useWorkspaceController", () => ({ useWorkspaceController: vi.fn() }));
type SelectorOverrides = { sidebarState?: Partial<SidebarStateReturn>; toolbarState?: Partial<ToolbarStateReturn>; editorPresentationState?: Partial<EditorPresentationStateReturn>; setMarkdownPreviewStyle?: (value: MarkdownPreviewStyle) => void; setShowSearch?: (value: boolean) => void; toggleSidebarCollapsed?: () => void; workspacePanelSidebarState?: Partial<WorkspacePanelSidebarStateReturn>; workspacePanelModeState?: Partial<WorkspacePanelModeStateReturn>; topBarsCollapsed?: TopBarsCollapsedReturn; statusBarCollapsed?: StatusBarCollapsedReturn;};
type WorkspacePanelPropOverrides = { toolbar?: Partial<WorkspacePanelProps["toolbar"]>; onOpenWelcomeTab?: WorkspacePanelProps["onOpenWelcomeTab"]; onOpenImportSheet?: WorkspacePanelProps["onOpenImportSheet"]; onOpenStandardSiteImportSheet?: WorkspacePanelProps["onOpenStandardSiteImportSheet"]; editor?: Partial<EditorProps>; preview?: Partial<PreviewProps>; statusBar?: Partial<StatusBarProps>; diagnostics?: Partial<WorkspaceDiagnosticsProps>; welcome?: Partial<NonNullable<WorkspacePanelProps["welcome"]>>; activeDocRelPath?: WorkspacePanelProps["activeDocRelPath"]; activeDocContentType?: WorkspacePanelProps["activeDocContentType"];};
const createSidebarState = (overrides: Partial<SidebarStateReturn> = {}): SidebarStateReturn => { const selectedLocationId = overrides.selectedLocationId; const documents = overrides.documents ?? []; const directories = overrides.directories ?? [];
return { locations: [], selectedLocationId, selectedDocPath: undefined, documents, directories, documentsByLocation: overrides.documentsByLocation ?? (selectedLocationId ? { [selectedLocationId]: documents } : {}), directoriesByLocation: overrides.directoriesByLocation ?? (selectedLocationId ? { [selectedLocationId]: directories } : {}), expandedLocationIds: overrides.expandedLocationIds ?? [], expandedDirectoriesByLocation: overrides.expandedDirectoriesByLocation ?? {}, isLoading: false, refreshingLocationId: undefined, sidebarRefreshReason: null, externalDropTargetId: undefined, externalDropFolderPath: undefined, activeDropTarget: null, folderSortOrderByLocation: {}, filterText: "", setFilterText: vi.fn(), setDocuments: vi.fn(), setDirectories: vi.fn(), setDocumentsForLocation: vi.fn(), setDirectoriesForLocation: vi.fn(), setSidebarTreeState: vi.fn(), selectLocation: vi.fn(), toggleExpandedLocation: vi.fn(), toggleExpandedDirectory: vi.fn(), expandDirectories: vi.fn(), collapseDirectories: vi.fn(), toggleSidebarCollapsed: vi.fn(), filenameVisibility: false, setExternalDropTarget: vi.fn(), setActiveDropTarget: vi.fn(), reorderFolderSortOrder: vi.fn(), ...overrides, };};
const createToolbarState = (overrides: Partial<ToolbarStateReturn> = {}): ToolbarStateReturn => ({ isSplitView: false, isFocusMode: false, isPreviewVisible: false, setEditorOnlyMode: vi.fn(), toggleSplitView: vi.fn(), toggleFocusMode: vi.fn(), togglePreviewVisible: vi.fn(), ...overrides,});
const createEditorPresentationState = ( overrides: Partial<EditorPresentationStateReturn> = {},): EditorPresentationStateReturn => ({ theme: "dark", showLineNumbers: true, textWrappingEnabled: true, syntaxHighlightingEnabled: true, fontSize: 16, fontFamily: "IBM Plex Mono", renderedFontFamily: "IBM Plex Mono", markdownPreviewStyle: "github", typewriterScrollingEnabled: false, focusDimmingMode: "off", posHighlightingEnabled: false, styleCheckSettings: { enabled: false, categories: { filler: true, redundancy: true, cliche: true }, customPatterns: [], markerStyle: "highlight", }, ...overrides,});
const createWorkspacePanelSidebarState = ( overrides: Partial<WorkspacePanelSidebarStateReturn> = {},): WorkspacePanelSidebarStateReturn => ({ sidebarCollapsed: true, ...overrides });
const createWorkspacePanelModeState = ( overrides: Partial<WorkspacePanelModeStateReturn> = {},): WorkspacePanelModeStateReturn => ({ isSplitView: false, isPreviewVisible: false, ...overrides });
const mockPanelSelectors = (overrides: SelectorOverrides = {}): void => { vi.mocked(useSidebarState).mockReturnValue(createSidebarState(overrides.sidebarState)); vi.mocked(useTabsState).mockReturnValue({ tabs: [], activeTabId: null, isSessionHydrated: true }); vi.mocked(useToolbarState).mockReturnValue(createToolbarState(overrides.toolbarState)); vi.mocked(useEditorPresentationActions).mockReturnValue({ setLineNumbersVisible: vi.fn(), toggleLineNumbersVisible: vi.fn(), setTextWrappingEnabled: vi.fn(), toggleTextWrappingEnabled: vi.fn(), setSyntaxHighlightingEnabled: vi.fn(), toggleSyntaxHighlightingEnabled: vi.fn(), setEditorFontSize: vi.fn(), setEditorFontFamily: vi.fn(), setRenderedFontFamily: vi.fn(), setMarkdownPreviewStyle: overrides.setMarkdownPreviewStyle ?? vi.fn(), }); vi.mocked(useLayoutChromeActions).mockReturnValue({ setSidebarCollapsed: vi.fn(), toggleSidebarCollapsed: overrides.toggleSidebarCollapsed ?? vi.fn(), setTopBarsCollapsed: vi.fn(), toggleTabBarCollapsed: vi.fn(), setStatusBarCollapsed: vi.fn(), toggleStatusBarCollapsed: vi.fn(), setShowSearch: overrides.setShowSearch ?? vi.fn(), toggleShowSearch: vi.fn(), setFilenameVisibility: vi.fn(), toggleFilenameVisibility: vi.fn(), }); vi.mocked(useLayoutChromeState).mockReturnValue({ sidebarCollapsed: overrides.workspacePanelSidebarState?.sidebarCollapsed ?? true, topBarsCollapsed: false, statusBarCollapsed: false, showSearch: false, reduceMotion: false, showFilenames: false, createReadmeInNewLocations: true, }); vi.mocked(useLayoutSettingsUiState).mockReturnValue({ isOpen: false, setOpen: vi.fn() }); vi.mocked(useEditorPresentationState).mockReturnValue( createEditorPresentationState(overrides.editorPresentationState), ); vi.mocked(useWorkspacePanelSidebarState).mockReturnValue( createWorkspacePanelSidebarState(overrides.workspacePanelSidebarState), ); vi.mocked(useWorkspacePanelModeState).mockReturnValue( createWorkspacePanelModeState(overrides.workspacePanelModeState), ); vi.mocked(useWorkspacePanelTopBarsCollapsed).mockReturnValue(overrides.topBarsCollapsed ?? true); vi.mocked(useWorkspacePanelStatusBarCollapsed).mockReturnValue(overrides.statusBarCollapsed ?? true); vi.mocked(useWorkspaceController).mockReturnValue({ locations: [], documents: [], documentsByLocation: {}, selectedLocationId: undefined, selectedDocPath: undefined, locationDocuments: [], sidebarFilter: "", isSidebarLoading: false, isSessionHydrated: true, refreshingLocationId: undefined, sidebarRefreshReason: null, tabs: [], activeTabId: null, activeTab: null, setSidebarFilter: vi.fn(), markActiveTabModified: vi.fn(), handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectLocation: vi.fn(), handleSelectDocument: vi.fn(), handleSelectTab: vi.fn(), handleCloseTab: vi.fn(), handleReorderTabs: vi.fn(), handleCreateDraftTab: vi.fn(), handleCreateNewDocument: vi.fn(), handleRefreshSidebar: vi.fn(), handleRenameDocument: vi.fn(), handleMoveDocument: vi.fn(), handleMoveDirectory: vi.fn(), handleDeleteDocument: vi.fn(), handleCreateDirectory: vi.fn(), handleImportExternalFile: vi.fn(), }); vi.mocked(useSidebarActions).mockReturnValue({ handleAddLocation: vi.fn(), handleRemoveLocation: vi.fn(), handleSelectDocument: vi.fn(), handleCreateNewDocument: vi.fn(), handleRefreshSidebar: vi.fn(), handleRenameDocument: vi.fn(), handleMoveDocument: vi.fn(), handleMoveDirectory: vi.fn(), handleDeleteDocument: vi.fn(), handleImportExternalFile: vi.fn(), });};
const createWorkspacePanelProps = (overrides: WorkspacePanelPropOverrides = {}): WorkspacePanelProps => ({ toolbar: { saveStatus: "Idle", onSave: vi.fn(), ...overrides.toolbar }, onOpenWelcomeTab: overrides.onOpenWelcomeTab, onOpenImportSheet: overrides.onOpenImportSheet, onOpenStandardSiteImportSheet: overrides.onOpenStandardSiteImportSheet, editor: { initialText: "# Document", onChange: vi.fn(), onSave: vi.fn(), onCursorMove: vi.fn(), onSelectionChange: vi.fn(), ...overrides.editor, }, preview: { renderResult: { html: "<p>Preview content</p>", metadata: { title: null, outline: [], links: [], task_items: { total: 0, completed: 0 }, word_count: 2 }, }, theme: "dark", editorLine: 1, previewStyle: "github", renderedFontFamily: "IBM Plex Mono", onScrollToLine: vi.fn(), ...overrides.preview, }, statusBar: { stats: { cursorLine: 1, cursorColumn: 1, wordCount: 0, charCount: 0 }, ...overrides.statusBar }, diagnostics: { isVisible: false, styleCheckEnabled: true, matches: [], onSelectMatch: vi.fn(), onClose: vi.fn(), onOpenSettings: vi.fn(), ...overrides.diagnostics, }, welcome: { isVisible: false, hasLocations: true, locationCount: 1, documentCount: 1, onAddLocation: vi.fn(), ...overrides.welcome, }, activeDocRelPath: overrides.activeDocRelPath, activeDocContentType: overrides.activeDocContentType,});
const renderWorkspacePanel = ( propOverrides: WorkspacePanelPropOverrides = {}, selectorOverrides: SelectorOverrides = {},) => { mockPanelSelectors(selectorOverrides); return render(<WorkspacePanel {...createWorkspacePanelProps(propOverrides)} />);};
describe("WorkspacePanel", () => { beforeEach(() => { vi.clearAllMocks(); });
it("renders preview-only mode when preview is enabled without split view", () => { const { container } = renderWorkspacePanel({ editor: { initialText: "# Hidden" } }, { toolbarState: { isPreviewVisible: true }, workspacePanelModeState: { isPreviewVisible: true }, });
expect(screen.getByText("Preview content")).toBeInTheDocument(); expect(container.querySelector("[data-testid='editor-container']")).not.toBeInTheDocument(); });
it("switches preview chrome between Reading and Web modes", () => { const setMarkdownPreviewStyle = vi.fn(); renderWorkspacePanel({ editor: { initialText: "# Visible" } }, { setMarkdownPreviewStyle, toolbarState: { isPreviewVisible: true }, workspacePanelModeState: { isPreviewVisible: true }, });
fireEvent.click(screen.getByRole("button", { name: /web/i })); fireEvent.click(screen.getByRole("button", { name: /reading/i }));
expect(setMarkdownPreviewStyle).toHaveBeenNthCalledWith(1, "pdf"); expect(setMarkdownPreviewStyle).toHaveBeenNthCalledWith(2, "github"); });
it("renders sidebar controls and supports resizing", () => { const onToggleSidebar = vi.fn();
renderWorkspacePanel({ editor: { initialText: "# Visible" } }, { toggleSidebarCollapsed: onToggleSidebar, workspacePanelSidebarState: { sidebarCollapsed: false }, });
fireEvent.click(screen.getByRole("button", { name: /hide sidebar/i })); expect(onToggleSidebar).toHaveBeenCalledOnce();
const separator = screen.getByRole("separator", { name: "Resize sidebar" }); const sidebarContainer = separator.parentElement as HTMLElement; const appWindow = globalThis as unknown as Window; expect(sidebarContainer).toHaveStyle({ width: "256px" });
fireEvent.pointerDown(separator, { clientX: 256 }); fireEvent.pointerMove(appWindow, { clientX: 360 }); fireEvent.pointerUp(appWindow);
expect(sidebarContainer).toHaveStyle({ width: "360px" }); });
it("renders split mode and supports resizing between editor and preview", () => { renderWorkspacePanel({ editor: { initialText: "# Split" } }, { toolbarState: { isSplitView: true, isPreviewVisible: true }, workspacePanelModeState: { isSplitView: true, isPreviewVisible: true }, });
const separator = screen.getByRole("separator", { name: "Resize split panes" }); const editorPane = screen.getByTestId("editor-container").parentElement as HTMLElement; const appWindow = globalThis as unknown as Window; const initialWidth = Number.parseFloat(editorPane.style.width);
fireEvent.pointerDown(separator, { clientX: initialWidth }); fireEvent.pointerMove(appWindow, { clientX: initialWidth + 120 }); fireEvent.pointerUp(appWindow);
expect(editorPane).toHaveStyle({ width: `${initialWidth + 120}px` }); });
it("falls back from split mode to editor-only when viewport is too narrow", () => { Object.defineProperty(globalThis, "innerWidth", { configurable: true, writable: true, value: 540 }); act(() => { globalThis.dispatchEvent(new Event("resize")); });
const { container } = renderWorkspacePanel({ editor: { initialText: "# Split fallback" } }, { toolbarState: { isSplitView: true, isPreviewVisible: true }, workspacePanelModeState: { isSplitView: true, isPreviewVisible: true }, });
expect(container.querySelector("[aria-label='Resize split panes']")).not.toBeInTheDocument(); expect(screen.queryByText("Preview content")).not.toBeInTheDocument(); expect(container.querySelector("[data-testid='editor-container']")).toBeInTheDocument();
Object.defineProperty(globalThis, "innerWidth", { configurable: true, writable: true, value: 1024 }); act(() => { globalThis.dispatchEvent(new Event("resize")); }); });
it("renders diagnostics panel and routes match actions", () => { const onSelectMatch = vi.fn(); const onClose = vi.fn(); const onOpenSettings = vi.fn(); const match = { from: 6, to: 15, text: "basically", category: "filler" as const, replacement: "remove", line: 1, column: 6, };
renderWorkspacePanel({ diagnostics: { isVisible: true, styleCheckEnabled: true, matches: [match], onSelectMatch, onClose, onOpenSettings, }, });
expect(screen.getByText("Style Check")).toBeInTheDocument(); fireEvent.click(screen.getByText("basically")); expect(onSelectMatch).toHaveBeenCalledWith(match);
fireEvent.click(screen.getByLabelText("Close diagnostics panel")); expect(onClose).toHaveBeenCalledOnce(); expect(onOpenSettings).not.toHaveBeenCalled(); });
it("routes tab-strip new tab actions to the welcome handler", () => { const onOpenWelcomeTab = vi.fn();
renderWorkspacePanel({ onOpenWelcomeTab, welcome: { isVisible: false } }, { topBarsCollapsed: false });
fireEvent.click(screen.getByRole("button", { name: /new tab/i })); expect(onOpenWelcomeTab).toHaveBeenCalledOnce(); });
it("renders welcome instead of image view when welcome is visible", () => { renderWorkspacePanel({ welcome: { isVisible: true, hasLocations: true, locationCount: 2, documentCount: 4, onAddLocation: vi.fn() }, activeDocRelPath: "images/cover.png", activeDocContentType: "image/png", });
expect(screen.getByTestId("workspace-welcome-screen")).toBeInTheDocument(); expect(screen.queryByText("Loading image...")).not.toBeInTheDocument(); });
it("renders the welcome screen and routes its actions", () => { const onNewDocument = vi.fn(); const onAddLocation = vi.fn(); const onOpenImportSheet = vi.fn(); const onOpenStandardSiteImportSheet = vi.fn(); const setShowSearch = vi.fn();
renderWorkspacePanel({ toolbar: { onNewDocument }, onOpenImportSheet, onOpenStandardSiteImportSheet, welcome: { isVisible: true, hasLocations: true, locationCount: 2, documentCount: 4, onAddLocation }, }, { workspacePanelSidebarState: { sidebarCollapsed: true }, setShowSearch });
expect(screen.getByTestId("workspace-welcome-screen")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /create new/i })); fireEvent.click(screen.getByRole("button", { name: /open existing/i })); fireEvent.click(screen.getByRole("button", { name: /import from tangled/i })); fireEvent.click(screen.getByRole("button", { name: /import standard\.site/i })); fireEvent.click(screen.getByRole("button", { name: /add another location/i }));
expect(onNewDocument).toHaveBeenCalledOnce(); expect(setShowSearch).toHaveBeenCalledWith(true); expect(onOpenImportSheet).toHaveBeenCalledOnce(); expect(onOpenStandardSiteImportSheet).toHaveBeenCalledOnce(); expect(onAddLocation).toHaveBeenCalledOnce(); });});