diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 7f2d0a404fb..7f28b6472b7 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -140,6 +140,7 @@ import { normalizePersistedWorkspaceStatuses, normalizeWorkspaceStatuses } from '../shared/workspace-statuses' +import { clampMarkdownTocPanelWidth } from '../shared/markdown-toc-panel-width' import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ownership' import { sanitizeRepoIcon } from '../shared/repo-icon' import { normalizeRepoBadgeColor } from '../shared/repo-badge-color' @@ -4527,6 +4528,7 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( this.state.ui?.workspaceBoardColumnWidth ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth(this.state.ui?.markdownTocPanelWidth), visibleWorkspaceHostIds: normalizeVisibleExecutionHostIds( this.state.ui?.visibleWorkspaceHostIds ), @@ -4597,6 +4599,9 @@ export class Store { workspaceBoardColumnWidth: clampWorkspaceBoardColumnWidth( sanitizedUpdates.workspaceBoardColumnWidth ?? this.state.ui?.workspaceBoardColumnWidth ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth( + sanitizedUpdates.markdownTocPanelWidth ?? this.state.ui?.markdownTocPanelWidth + ), visibleWorkspaceHostIds: updates.visibleWorkspaceHostIds !== undefined ? normalizeVisibleExecutionHostIds(updates.visibleWorkspaceHostIds) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index c65eed036de..bd1a40ecaf8 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -150,6 +150,7 @@ const UiUpdate = z .optional(), rightSidebarExplorerView: z.enum(['files', 'search']).optional(), rightSidebarWidth: z.number().finite().optional(), + markdownTocPanelWidth: z.number().finite().optional(), groupBy: z.enum(['none', 'workspace-status', 'repo', 'pr-status']).optional(), showWorkspaceLineage: z.boolean().optional(), sortBy: z.enum(['name', 'smart', 'recent', 'repo', 'manual']).optional(), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index bc28cd4b58f..a0518e205b9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -544,6 +544,7 @@ function App(): React.JSX.Element { const shouldMountSetupGuideTelemetryObserver = persistedUIReady const shouldMountUpdateCard = shouldMountUpdateCardForStatus(updateStatus) const rightSidebarWidth = useAppStore((s) => s.rightSidebarWidth) + const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth) const rightSidebarOpen = useAppStore((s) => s.rightSidebarOpen) const rightSidebarTab = useAppStore((s) => s.rightSidebarTab) const rightSidebarExplorerView = useAppStore((s) => s.rightSidebarExplorerView) @@ -1174,6 +1175,7 @@ function App(): React.JSX.Element { rightSidebarTab, rightSidebarExplorerView, rightSidebarWidth, + markdownTocPanelWidth, groupBy, sortBy, projectOrderBy, @@ -1200,6 +1202,7 @@ function App(): React.JSX.Element { rightSidebarTab, rightSidebarExplorerView, rightSidebarWidth, + markdownTocPanelWidth, groupBy, sortBy, projectOrderBy, diff --git a/src/renderer/src/assets/markdown-preview.css b/src/renderer/src/assets/markdown-preview.css index 309d5c4367f..9993ac660ba 100644 --- a/src/renderer/src/assets/markdown-preview.css +++ b/src/renderer/src/assets/markdown-preview.css @@ -93,10 +93,8 @@ } .markdown-toc-panel { + position: relative; display: flex; - width: 240px; - min-width: 200px; - max-width: 30%; flex-shrink: 0; flex-direction: column; border-right: 1px solid color-mix(in srgb, var(--border) 72%, transparent); @@ -228,13 +226,6 @@ font-size: 12px; } -@media (max-width: 900px) { - .markdown-toc-panel { - width: 200px; - max-width: 42%; - } -} - @container (max-width: 560px) { .markdown-toc-panel { position: absolute; @@ -242,9 +233,6 @@ left: 0; bottom: 0; z-index: 30; - width: min(240px, 72cqw); - min-width: 0; - max-width: calc(100cqw - 44px); box-shadow: 10px 0 24px rgb(0 0 0 / 0.14); } } diff --git a/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx b/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx index 8556f4e5e8c..8f1d542140f 100644 --- a/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx +++ b/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.test.tsx @@ -30,5 +30,7 @@ describe('MarkdownTableOfContentsPanel', () => { expect(html).toContain('Collapse Intro') expect(html).toContain('Intro') expect(html).toContain('Setup') + expect(html).toContain('data-markdown-toc-resize-handle') + expect(html).toContain('Resize table of contents') }) }) diff --git a/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.tsx b/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.tsx index 678877de8bb..a6a822fbea4 100644 --- a/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.tsx +++ b/src/renderer/src/components/editor/MarkdownTableOfContentsPanel.tsx @@ -10,6 +10,14 @@ import { toggleMarkdownTocCollapsedId } from './markdown-toc-collapse-state' import { translate } from '@/i18n/i18n' +import { useSidebarResize } from '@/hooks/useSidebarResize' +import { useAppStore } from '@/store' +import { + MARKDOWN_TOC_PANEL_MIN_WIDTH, + MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME, + clampMarkdownTocPanelWidth, + computeMaxMarkdownTocPanelWidth +} from './markdown-toc-panel-width' type MarkdownTableOfContentsPanelProps = { items: MarkdownTocItem[] @@ -106,11 +114,44 @@ export function MarkdownTableOfContentsPanel({ onNavigate }: MarkdownTableOfContentsPanelProps): React.JSX.Element { const [collapsedIds, setCollapsedIds] = useState>(() => new Set()) + const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth) + const setMarkdownTocPanelWidth = useAppStore((s) => s.setMarkdownTocPanelWidth) + const [layoutWidth, setLayoutWidth] = useState(null) + const maxPanelWidth = computeMaxMarkdownTocPanelWidth(layoutWidth ?? 0) + const renderedPanelWidth = clampMarkdownTocPanelWidth( + markdownTocPanelWidth, + layoutWidth ?? undefined + ) + const { containerRef, onResizeStart } = useSidebarResize({ + isOpen: true, + width: renderedPanelWidth, + minWidth: MARKDOWN_TOC_PANEL_MIN_WIDTH, + maxWidth: maxPanelWidth, + deltaSign: 1, + setWidth: setMarkdownTocPanelWidth + }) useEffect(() => { setCollapsedIds((current) => pruneMarkdownTocCollapsedIds(current, items)) }, [items]) + useEffect(() => { + const container = containerRef.current + const layout = container?.parentElement + if (!layout) { + return + } + + const updateMaxWidth = (): void => { + setLayoutWidth(layout.clientWidth) + } + + updateMaxWidth() + const observer = new ResizeObserver(updateMaxWidth) + observer.observe(layout) + return () => observer.disconnect() + }, [containerRef]) + const collapseToLevel = (level: MarkdownTocLevel): void => { setCollapsedIds(collapseMarkdownTocToLevel(items, level)) } @@ -121,6 +162,7 @@ export function MarkdownTableOfContentsPanel({ return ( ) } diff --git a/src/renderer/src/components/editor/markdown-toc-panel-width.ts b/src/renderer/src/components/editor/markdown-toc-panel-width.ts new file mode 100644 index 00000000000..b6887f61735 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-toc-panel-width.ts @@ -0,0 +1,11 @@ +export { + MARKDOWN_TOC_PANEL_DEFAULT_WIDTH, + MARKDOWN_TOC_PANEL_MAX_WIDTH, + MARKDOWN_TOC_PANEL_MIN_WIDTH, + clampMarkdownTocPanelWidth, + computeMaxMarkdownTocPanelWidth +} from '../../../../shared/markdown-toc-panel-width' + +// Why: match the worktree/right sidebar 4px resize target; a 1px seam is too hard to acquire. +export const MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME = + 'absolute top-0 right-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-ring/20 active:bg-ring/30' diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index c6eeea3a811..120165cbc99 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10058,7 +10058,8 @@ "06357eea60": "Table of Contents", "27d0a9c49a": "Table of contents", "65b036a6c8": "Expand {{value0}}", - "97ad46f11f": "Collapse {{value0}}" + "97ad46f11f": "Collapse {{value0}}", + "8f4d2c1a9b": "Resize table of contents" }, "MarkdownTemplatePicker": { "22cd94426f": "untitled.md", diff --git a/src/renderer/src/lib/startup-ui-hydration.ts b/src/renderer/src/lib/startup-ui-hydration.ts index 9467211dbaa..85837b2c063 100644 --- a/src/renderer/src/lib/startup-ui-hydration.ts +++ b/src/renderer/src/lib/startup-ui-hydration.ts @@ -39,6 +39,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', rightSidebarWidth: 350, + markdownTocPanelWidth: 240, groupBy: 'repo', sortBy: 'name', projectOrderBy: 'manual', diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index aca16ef2c57..9fe672cadb7 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -35,6 +35,7 @@ import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action' import { shouldForcePushWithLeaseForUpstream } from '../../../../shared/git-upstream-status' @@ -333,6 +334,10 @@ export type EditorSlice = { markdownFrontmatterVisible: Record setMarkdownFrontmatterVisible: (fileId: string, visible: boolean) => void + // Markdown table of contents + markdownTocPanelWidth: number + setMarkdownTocPanelWidth: (width: number) => void + // Right sidebar rightSidebarOpen: boolean rightSidebarWidth: number @@ -1451,6 +1456,13 @@ export const createEditorSlice: StateCreator = (s return { markdownFrontmatterVisible: { ...s.markdownFrontmatterVisible, [fileId]: true } } }), + // Markdown table of contents + markdownTocPanelWidth: 240, + setMarkdownTocPanelWidth: (width) => + set((s) => ({ + markdownTocPanelWidth: clampMarkdownTocPanelWidth(width, undefined, s.markdownTocPanelWidth) + })), + // Right sidebar rightSidebarOpen: false, rightSidebarWidth: 280, diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index 57f05c45484..abdf3b34c15 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -70,6 +70,7 @@ function createUIStore(): StoreApi { worktreesByRepo: {}, rightSidebarOpen: false, rightSidebarWidth: 280, + markdownTocPanelWidth: 240, rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', ...createSettingsSearchState(args[0]), @@ -868,6 +869,18 @@ describe('createUISlice hydratePersistedUI', () => { expect(store.getState().rightSidebarWidth).toBe(220) }) + it('clamps persisted markdown toc panel widths into the supported range', () => { + const store = createUIStore() + + store.getState().hydratePersistedUI( + makePersistedUI({ + markdownTocPanelWidth: 100 + }) + ) + + expect(store.getState().markdownTocPanelWidth).toBe(200) + }) + it('preserves right sidebar widths above the former 500px cap', () => { const store = createUIStore() diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index de7aab3cdea..8e174679c80 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -81,6 +81,7 @@ import { cloneDefaultWorkspaceStatuses, normalizeWorkspaceStatuses } from '../../../../shared/workspace-statuses' +import { clampMarkdownTocPanelWidth } from '../../../../shared/markdown-toc-panel-width' import { normalizeKagiSessionLink } from '../../../../shared/browser-url' import type { OrcaHookScriptKind } from '../../lib/orca-hook-trust' import type { SettingsNavTarget } from '@/lib/settings-navigation-types' @@ -2135,6 +2136,11 @@ export const createUISlice: StateCreator = (set, get) s.rightSidebarWidth, MAX_RIGHT_SIDEBAR_WIDTH ), + markdownTocPanelWidth: clampMarkdownTocPanelWidth( + ui.markdownTocPanelWidth, + undefined, + s.markdownTocPanelWidth + ), rightSidebarOpen: typeof ui.rightSidebarOpen === 'boolean' ? ui.rightSidebarOpen : true, rightSidebarTab: rightSidebarRoute.rightSidebarTab, rightSidebarExplorerView: rightSidebarRoute.rightSidebarExplorerView, diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 926163bf1b4..2177a126bd2 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -424,6 +424,7 @@ export function getDefaultUIState(): PersistedUIState { rightSidebarTab: 'explorer', rightSidebarExplorerView: 'files', rightSidebarWidth: 350, + markdownTocPanelWidth: 240, groupBy: 'repo', sortBy: 'recent', projectOrderBy: 'manual', diff --git a/src/shared/markdown-toc-panel-width.test.ts b/src/shared/markdown-toc-panel-width.test.ts new file mode 100644 index 00000000000..35572c0a6d1 --- /dev/null +++ b/src/shared/markdown-toc-panel-width.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { + MARKDOWN_TOC_PANEL_DEFAULT_WIDTH, + MARKDOWN_TOC_PANEL_MAX_WIDTH, + MARKDOWN_TOC_PANEL_MIN_WIDTH, + clampMarkdownTocPanelWidth, + computeMaxMarkdownTocPanelWidth +} from './markdown-toc-panel-width' + +describe('markdown toc panel width', () => { + it('clamps widths into the supported range', () => { + expect(clampMarkdownTocPanelWidth(undefined)).toBe(MARKDOWN_TOC_PANEL_DEFAULT_WIDTH) + expect(clampMarkdownTocPanelWidth(100)).toBe(MARKDOWN_TOC_PANEL_MIN_WIDTH) + expect(clampMarkdownTocPanelWidth(900)).toBe(MARKDOWN_TOC_PANEL_MAX_WIDTH) + }) + + it('respects the remaining editor width when a container size is known', () => { + expect(computeMaxMarkdownTocPanelWidth(700)).toBe(380) + expect(clampMarkdownTocPanelWidth(500, 700)).toBe(380) + expect(clampMarkdownTocPanelWidth(350, 700)).toBe(350) + }) + + it('treats the second argument as container width, not a precomputed max', () => { + const maxFor700 = computeMaxMarkdownTocPanelWidth(700) + expect(clampMarkdownTocPanelWidth(350, maxFor700)).toBe(200) + expect(clampMarkdownTocPanelWidth(350, 700)).toBe(350) + }) +}) diff --git a/src/shared/markdown-toc-panel-width.ts b/src/shared/markdown-toc-panel-width.ts new file mode 100644 index 00000000000..133bdd9db6c --- /dev/null +++ b/src/shared/markdown-toc-panel-width.ts @@ -0,0 +1,32 @@ +export const MARKDOWN_TOC_PANEL_MIN_WIDTH = 200 +export const MARKDOWN_TOC_PANEL_DEFAULT_WIDTH = 240 +export const MARKDOWN_TOC_PANEL_MIN_EDITOR_WIDTH = 320 +export const MARKDOWN_TOC_PANEL_MAX_WIDTH = 600 + +export function computeMaxMarkdownTocPanelWidth(containerWidth: number): number { + if (!Number.isFinite(containerWidth) || containerWidth <= 0) { + return MARKDOWN_TOC_PANEL_MAX_WIDTH + } + + return Math.min( + MARKDOWN_TOC_PANEL_MAX_WIDTH, + Math.max(MARKDOWN_TOC_PANEL_MIN_WIDTH, containerWidth - MARKDOWN_TOC_PANEL_MIN_EDITOR_WIDTH) + ) +} + +export function clampMarkdownTocPanelWidth( + width: unknown, + containerWidth?: number, + fallback = MARKDOWN_TOC_PANEL_DEFAULT_WIDTH +): number { + if (typeof width !== 'number' || !Number.isFinite(width)) { + return fallback + } + + const maxWidth = + containerWidth !== undefined + ? computeMaxMarkdownTocPanelWidth(containerWidth) + : MARKDOWN_TOC_PANEL_MAX_WIDTH + + return Math.min(maxWidth, Math.max(MARKDOWN_TOC_PANEL_MIN_WIDTH, width)) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 60918437ad8..cc6ad66414b 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2931,6 +2931,7 @@ export type PersistedUIState = { rightSidebarTab: RightSidebarTab rightSidebarExplorerView: RightSidebarExplorerView rightSidebarWidth: number + markdownTocPanelWidth?: number groupBy: 'none' | 'workspace-status' | 'repo' | 'pr-status' sortBy: 'name' | 'smart' | 'recent' | 'repo' | 'manual' /** Project header ordering in `groupBy: 'repo'`, independent of workspace