mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Implement resizable Markdown table of contents panel (#5444)
* Implement resizable Markdown table of contents panel Introduce resizable behavior to the Markdown table of contents panel, allowing users to adjust its width via a draggable handle. - Persist the custom panel width in the UI state and main process store. - Clamp the width to a safe range (200px to 600px) based on layout space. - Ensure the remaining editor workspace does not shrink below 320px by computing constraints dynamically against the parent container width. * Clone process env without undefined values for Windows preflight - Filter out undefined values when cloning process.env to prevent potential serialization or typing issues. - Update buildLocalPreflightEnv return type to Record<string, string>.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<Set<string>>(() => new Set())
|
||||
const markdownTocPanelWidth = useAppStore((s) => s.markdownTocPanelWidth)
|
||||
const setMarkdownTocPanelWidth = useAppStore((s) => s.setMarkdownTocPanelWidth)
|
||||
const [layoutWidth, setLayoutWidth] = useState<number | null>(null)
|
||||
const maxPanelWidth = computeMaxMarkdownTocPanelWidth(layoutWidth ?? 0)
|
||||
const renderedPanelWidth = clampMarkdownTocPanelWidth(
|
||||
markdownTocPanelWidth,
|
||||
layoutWidth ?? undefined
|
||||
)
|
||||
const { containerRef, onResizeStart } = useSidebarResize<HTMLElement>({
|
||||
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 (
|
||||
<aside
|
||||
ref={containerRef}
|
||||
className="markdown-toc-panel"
|
||||
aria-label={translate(
|
||||
'auto.components.editor.MarkdownTableOfContentsPanel.27d0a9c49a',
|
||||
@@ -220,6 +262,17 @@ export function MarkdownTableOfContentsPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
data-markdown-toc-resize-handle=""
|
||||
className={MARKDOWN_TOC_RESIZE_HANDLE_CLASS_NAME}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={translate(
|
||||
'auto.components.editor.MarkdownTableOfContentsPanel.8f4d2c1a9b',
|
||||
'Resize table of contents'
|
||||
)}
|
||||
onMouseDown={onResizeStart}
|
||||
/>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
@@ -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",
|
||||
|
||||
@@ -39,6 +39,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
|
||||
rightSidebarTab: 'explorer',
|
||||
rightSidebarExplorerView: 'files',
|
||||
rightSidebarWidth: 350,
|
||||
markdownTocPanelWidth: 240,
|
||||
groupBy: 'repo',
|
||||
sortBy: 'name',
|
||||
projectOrderBy: 'manual',
|
||||
|
||||
@@ -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<string, boolean>
|
||||
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<AppState, [], [], EditorSlice> = (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,
|
||||
|
||||
@@ -70,6 +70,7 @@ function createUIStore(): StoreApi<AppState> {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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<AppState, [], [], UISlice> = (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,
|
||||
|
||||
@@ -424,6 +424,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
rightSidebarTab: 'explorer',
|
||||
rightSidebarExplorerView: 'files',
|
||||
rightSidebarWidth: 350,
|
||||
markdownTocPanelWidth: 240,
|
||||
groupBy: 'repo',
|
||||
sortBy: 'recent',
|
||||
projectOrderBy: 'manual',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user